diff --git a/services/retriever/.env.example b/services/retriever/.env.example index 06a620a..fd8d260 100644 --- a/services/retriever/.env.example +++ b/services/retriever/.env.example @@ -59,6 +59,10 @@ GCP_PROJECT_ID= # OpenTelemetry — leave empty for local dev (no collector running by default). OTEL_EXPORTER_OTLP_ENDPOINT= +# Rate limiting (ADR-0012: slowapi, per-user limit on /api/v1/ask). +# RATE_LIMIT_ENABLED=true +# RATE_LIMIT_ASK=10/minute + # App DEBUG=true ALLOWED_ORIGINS=["http://localhost:5173"] diff --git a/services/retriever/pyproject.toml b/services/retriever/pyproject.toml index b1f18a5..4236eb6 100644 --- a/services/retriever/pyproject.toml +++ b/services/retriever/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "python-multipart>=0.0.18", "docling>=2.94.0", "docling-core[chunking-openai]>=2.0", + "slowapi>=0.1.9", # Direct floor to force the transitive transformers pin above the RCE in # CVE-2026-4372 (Dependabot alert #95). transformers arrives via # docling-core[chunking-openai] and docling-ibm-models. See issue #188. @@ -114,6 +115,11 @@ module = ["docling.*", "docling_core.*"] ignore_missing_imports = true follow_imports = "skip" +[[tool.mypy.overrides]] +module = ["slowapi", "slowapi.*"] +ignore_missing_imports = true +follow_imports = "skip" + # boto3/botocore are an optional, lazily-imported dependency for R2 storage and # are not installed; their imports live inside build_r2_storage. [[tool.mypy.overrides]] diff --git a/services/retriever/src/retriever/config.py b/services/retriever/src/retriever/config.py index dbaee2d..2dfd3a9 100644 --- a/services/retriever/src/retriever/config.py +++ b/services/retriever/src/retriever/config.py @@ -134,6 +134,10 @@ class Settings(BaseSettings): # only for gateways that actually implement it. moderation_backend: Literal["guardrails", "openai_api"] = "guardrails" + # Rate limiting (ADR-0012) + rate_limit_enabled: bool = True + rate_limit_ask: str = "10/minute" + # RAG rag_top_k: int = 5 diff --git a/services/retriever/src/retriever/infrastructure/rate_limit.py b/services/retriever/src/retriever/infrastructure/rate_limit.py new file mode 100644 index 0000000..eef61a7 --- /dev/null +++ b/services/retriever/src/retriever/infrastructure/rate_limit.py @@ -0,0 +1,122 @@ +# Copyright (C) 2025 Backchain LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Per-user request rate limiting for the RAG ask endpoint (ADR-0012). + +Guards ``POST /api/v1/ask`` against denial-of-wallet: a single authenticated, +subscribed token could otherwise drive unbounded paid LLM inference. The +limiter keys on the caller's Supabase user id (``AuthUser.sub``), stashed onto +``request.state.rate_limit_key`` by a route dependency, so the limit tracks +one bucket per user rather than one bucket per process or per remote address. + +Per-tenant limiting is a documented forward extension only, not implemented +here: the JWT this service validates carries no tenant claim, so keying on +the constant ``DEFAULT_TENANT_ID`` would collapse every user in the system +into a single global bucket rather than isolating tenants from each other. + +Storage is in-memory (``memory://``, slowapi's default) -- per ADR-0012 this +resets on restart and limits are per-instance, which is acceptable for a +single-instance deployment. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from typing import cast + +from fastapi import FastAPI, Request +from limits import parse_many +from slowapi import Limiter +from slowapi.errors import RateLimitExceeded +from starlette.responses import JSONResponse + +from retriever.config import get_settings + +# Fallback Retry-After (seconds) when slowapi hasn't recorded window state on +# the request (defensive; should not happen once configure_rate_limiting has +# wired the limiter onto the app). +_DEFAULT_RETRY_AFTER_SECONDS = 60 + +RATE_LIMIT_MESSAGE = ( + "Too many requests. Please wait a moment before asking another question." +) + + +def rate_limit_key(request: Request) -> str: + """Resolve the slowapi bucket key for the current request. + + Prefers the per-user identity stashed by + :func:`retriever.modules.rag.routes._stash_rate_limit_identity` (the + authenticated user's ``sub``); falls back to the remote address for any + request that reaches the limiter without that identity attached. + """ + stashed = getattr(request.state, "rate_limit_key", None) + if isinstance(stashed, str) and stashed: + return stashed + return request.client.host if request.client else "127.0.0.1" + + +limiter = Limiter(key_func=rate_limit_key, headers_enabled=False) + + +def _ask_limit_value() -> str: + """Return the current ``/ask`` limit string, re-read from settings.""" + return get_settings().rate_limit_ask + + +def _rate_limiting_disabled() -> bool: + """Return True when rate limiting is disabled via settings.""" + return not get_settings().rate_limit_enabled + + +# slowapi re-evaluates both callables on every request, so toggling +# RATE_LIMIT_ENABLED or RATE_LIMIT_ASK at runtime (e.g. via env in tests) +# takes effect without re-registering the decorator. +_ask_limit_decorator = limiter.limit( + _ask_limit_value, exempt_when=_rate_limiting_disabled +) + + +def ask_rate_limit[F: Callable[..., object]](func: F) -> F: + """Typed shim around ``limiter.limit`` for the ``/ask`` endpoint. + + slowapi's ``Limiter.limit`` is untyped, which trips mypy --strict's + ``disallow_untyped_decorators``. Casting the wrapped callable back to + ``F`` keeps the decorated route's signature visible to type checkers. + """ + return cast("F", _ask_limit_decorator(func)) + + +def _retry_after_seconds(request: Request) -> int: + """Compute a Retry-After value (seconds) from slowapi's window state.""" + current = getattr(request.state, "view_rate_limit", None) + if current is None: + return _DEFAULT_RETRY_AFTER_SECONDS + window_stats = limiter.limiter.get_window_stats(current[0], *current[1]) + return max(1, int(1 + window_stats[0] - time.time())) + + +async def rate_limit_exceeded_handler(request: Request, exc: Exception) -> JSONResponse: + """Render the ADR-0012 429 body for a :class:`RateLimitExceeded` error.""" + retry_after = _retry_after_seconds(request) + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "message": RATE_LIMIT_MESSAGE, + "retry_after": retry_after, + }, + headers={"Retry-After": str(retry_after)}, + ) + + +def configure_rate_limiting(app: FastAPI) -> None: + """Wire the ADR-0012 limiter and its 429 handler onto ``app``. + + Parses the configured ``/ask`` limit up front so an invalid limit string + fails fast at startup rather than on the first request. + """ + parse_many(get_settings().rate_limit_ask) + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) diff --git a/services/retriever/src/retriever/main.py b/services/retriever/src/retriever/main.py index b03c54e..a2e594c 100644 --- a/services/retriever/src/retriever/main.py +++ b/services/retriever/src/retriever/main.py @@ -27,6 +27,7 @@ RequestIdMiddleware, ) from retriever.infrastructure.observability.tracing import configure_tracing +from retriever.infrastructure.rate_limit import configure_rate_limiting from retriever.modules.auth import require_subscription from retriever.modules.documents.routes import router as documents_router from retriever.modules.messages.routes import router as messages_router @@ -174,6 +175,11 @@ def create_app() -> FastAPI: allow_headers=["Authorization", "Content-Type", "X-Request-ID"], ) + # ADR-0012 rate limiting: registered after CORS so a 429 raised inside the + # router stack still exits back out through the CORS middleware above it + # and carries Access-Control-Allow-Origin. + configure_rate_limiting(app) + # /api/v1 routes require an active "retriever" subscription (claim-based, # no DB read — see docs/subscriptions.md). Health has no /api/v1 prefix # and stays ungated so orchestrators can probe liveness without a token. diff --git a/services/retriever/src/retriever/modules/rag/routes.py b/services/retriever/src/retriever/modules/rag/routes.py index 1d58f99..b8a96b2 100644 --- a/services/retriever/src/retriever/modules/rag/routes.py +++ b/services/retriever/src/retriever/modules/rag/routes.py @@ -9,10 +9,11 @@ from typing import Annotated import structlog -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, ConfigDict, Field from retriever.config import get_settings +from retriever.infrastructure.rate_limit import ask_rate_limit from retriever.models.user import DEFAULT_TENANT_ID from retriever.modules.auth import AuthUser, require_auth from retriever.modules.messages.repos import MessageRepository @@ -65,10 +66,28 @@ def _to_ask_response(rag_response: RAGResponse) -> AskResponse: ) +async def _stash_rate_limit_identity( + request: Request, + user: Annotated[AuthUser, Depends(require_auth)], +) -> AuthUser: + """Expose the authenticated user's id to slowapi's rate-limit key_func. + + Chains off ``require_auth`` (FastAPI caches the dependency per request, + so this costs no extra JWT decode) and stashes ``user.sub`` onto + ``request.state`` where :func:`retriever.infrastructure.rate_limit.rate_limit_key` + reads it. This keys the ADR-0012 limit per authenticated user rather than + per remote address. + """ + request.state.rate_limit_key = user.sub + return user + + @router.post("/ask", response_model=AskResponse) +@ask_rate_limit async def ask( + request: Request, body: AskRequest, - user: Annotated[AuthUser, Depends(require_auth)], + user: Annotated[AuthUser, Depends(_stash_rate_limit_identity)], rag_service: Annotated[RAGService, Depends(get_rag_service)], message_repo: Annotated[MessageRepository, Depends(get_message_repository)], ) -> AskResponse: diff --git a/services/retriever/tests/conftest.py b/services/retriever/tests/conftest.py index a5d4e99..4749899 100644 --- a/services/retriever/tests/conftest.py +++ b/services/retriever/tests/conftest.py @@ -20,6 +20,7 @@ create_async_engine, ) +from retriever.infrastructure.rate_limit import limiter from retriever.models.base import Base TEST_DATABASE_URL = os.getenv( @@ -34,6 +35,18 @@ def pytest_configure(config: pytest.Config) -> None: ) +@pytest.fixture(autouse=True) +def _reset_rate_limiter() -> None: + """Reset slowapi's in-memory rate-limit windows between tests. + + The limiter is a module-level singleton (see + retriever.infrastructure.rate_limit), so without this reset, request + counts from one test's /ask calls would bleed into the next test's + window and produce flaky 429s (e.g. in test_rag_routes.py). + """ + limiter.reset() + + @pytest_asyncio.fixture async def db_engine() -> AsyncEngine: # type: ignore[return] """Create all tables for one test; drop them after the test completes.""" diff --git a/services/retriever/tests/integration/test_rate_limit_and_concurrency.py b/services/retriever/tests/integration/test_rate_limit_and_concurrency.py index 02e4c00..ec16c1f 100644 --- a/services/retriever/tests/integration/test_rate_limit_and_concurrency.py +++ b/services/retriever/tests/integration/test_rate_limit_and_concurrency.py @@ -1,11 +1,11 @@ """Concurrent-request handling and burst behavior for the retriever API. -There is no app-level rate limiter in this service today (no slowapi -middleware, no 429 responses anywhere in the routes). The burst test below -therefore asserts service stability under load (no 5xx) rather than a -specific 429 response, and is forward-compatible: the allowed-status set -already includes 429 so this test keeps passing unmodified if a limiter is -added later. +``POST /api/v1/ask`` now carries the ADR-0012 per-user rate limit (slowapi, +in-memory, keyed on the authenticated user's Supabase id). The burst test +below targets ``GET /api/v1/documents``, which is unaffected by that limit, +so it asserts service stability under load (no 5xx) rather than a specific +429 response; its allowed-status set already includes 429, so it keeps +passing unmodified if a limiter is ever extended to this endpoint too. """ from __future__ import annotations diff --git a/services/retriever/tests/test_config.py b/services/retriever/tests/test_config.py index f867fb7..1a8d6ff 100644 --- a/services/retriever/tests/test_config.py +++ b/services/retriever/tests/test_config.py @@ -164,3 +164,10 @@ def test_gateway_token_for_moderation_scope() -> None: settings.gateway_token_for("moderation").get_secret_value() == "moderation-token" ) + + +def test_rate_limit_defaults() -> None: + """Rate limiting is enabled by default with a 10/minute limit on /ask.""" + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.rate_limit_enabled is True + assert settings.rate_limit_ask == "10/minute" diff --git a/services/retriever/tests/test_rate_limit.py b/services/retriever/tests/test_rate_limit.py new file mode 100644 index 0000000..6bf0f0d --- /dev/null +++ b/services/retriever/tests/test_rate_limit.py @@ -0,0 +1,277 @@ +"""Unit tests for the ADR-0012 per-user rate limiter on POST /api/v1/ask.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient +from slowapi.errors import RateLimitExceeded +from starlette.requests import Request + +from retriever.config import Settings +from retriever.infrastructure.rate_limit import ( + RATE_LIMIT_MESSAGE, + _retry_after_seconds, + configure_rate_limiting, + limiter, + rate_limit_key, +) +from retriever.modules.auth import AuthUser +from retriever.modules.auth.dependencies import require_auth +from retriever.modules.messages.repos import MessageRepository +from retriever.modules.rag.dependencies import get_message_repository, get_rag_service +from retriever.modules.rag.routes import router +from retriever.modules.rag.schemas import ChunkWithScore, RAGResponse +from retriever.modules.rag.service import RAGService + +TEST_USER = AuthUser( + sub="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email="test@example.com", + is_admin=False, +) + + +def _build_app( + mock_rag: RAGService, + mock_repo: MessageRepository, + *, + authenticated: bool = True, +) -> FastAPI: + """Create a test FastAPI app with dependency overrides and the limiter wired up.""" + app = FastAPI() + app.include_router(router) + + app.dependency_overrides[get_rag_service] = lambda: mock_rag + app.dependency_overrides[get_message_repository] = lambda: mock_repo + + if authenticated: + app.dependency_overrides[require_auth] = lambda: TEST_USER + + configure_rate_limiting(app) + return app + + +def _make_rag_response( + *, + answer: str = "Test answer", + blocked: bool = False, + blocked_reason: str | None = None, +) -> RAGResponse: + """Create a RAGResponse for testing.""" + chunks = [ + ChunkWithScore( + content="chunk content", + source="test.md", + section="intro", + score=0.85, + title="Test Doc", + ), + ] + return RAGResponse( + answer=answer, + chunks_used=chunks, + question="What is the policy?", + confidence_level="high", + confidence_score=0.9, + blocked=blocked, + blocked_reason=blocked_reason, + ) + + +def _make_mocks() -> tuple[RAGService, MessageRepository]: + """Create a mock RAGService and MessageRepository pair for /ask requests.""" + mock_rag = AsyncMock(spec=RAGService) + mock_rag.ask.return_value = _make_rag_response() + + mock_repo = AsyncMock(spec=MessageRepository) + mock_repo.get_recent_messages.return_value = [] + mock_repo.save_message = AsyncMock() + + return mock_rag, mock_repo + + +@pytest.fixture(autouse=True) +def _deterministic_rate_limit(monkeypatch: pytest.MonkeyPatch) -> None: + """Pin the /ask limit to a small, deterministic value for these tests.""" + monkeypatch.setattr( + "retriever.infrastructure.rate_limit.get_settings", + lambda: Settings( + _env_file=None, + rate_limit_ask="2/minute", + rate_limit_enabled=True, + ), + ) + + +def test_third_request_within_window_returns_429() -> None: + mock_rag, mock_repo = _make_mocks() + app = _build_app(mock_rag, mock_repo) + client = TestClient(app, raise_server_exceptions=True) + + resp1 = client.post("/api/v1/ask", json={"question": "hi"}) + resp2 = client.post("/api/v1/ask", json={"question": "hi"}) + resp3 = client.post("/api/v1/ask", json={"question": "hi"}) + + assert resp1.status_code == 200 + assert resp2.status_code == 200 + assert resp3.status_code == 429 + + +def test_429_carries_retry_after_and_adr_body() -> None: + mock_rag, mock_repo = _make_mocks() + app = _build_app(mock_rag, mock_repo) + client = TestClient(app, raise_server_exceptions=True) + + client.post("/api/v1/ask", json={"question": "hi"}) + client.post("/api/v1/ask", json={"question": "hi"}) + resp = client.post("/api/v1/ask", json={"question": "hi"}) + + assert resp.status_code == 429 + header = resp.headers.get("Retry-After") + assert header is not None + retry_after = int(header) + assert 1 <= retry_after <= 61 + assert resp.json() == { + "error": "rate_limit_exceeded", + "message": RATE_LIMIT_MESSAGE, + "retry_after": retry_after, + } + + +def test_rate_limited_request_never_reaches_rag_service() -> None: + mock_rag, mock_repo = _make_mocks() + app = _build_app(mock_rag, mock_repo) + client = TestClient(app, raise_server_exceptions=True) + + client.post("/api/v1/ask", json={"question": "hi"}) + client.post("/api/v1/ask", json={"question": "hi"}) + resp = client.post("/api/v1/ask", json={"question": "hi"}) + + assert resp.status_code == 429 + assert mock_rag.ask.await_count == 2 + + +def test_under_limit_requests_return_200_and_reach_rag() -> None: + mock_rag, mock_repo = _make_mocks() + app = _build_app(mock_rag, mock_repo) + client = TestClient(app, raise_server_exceptions=True) + + resp1 = client.post("/api/v1/ask", json={"question": "hi"}) + resp2 = client.post("/api/v1/ask", json={"question": "hi"}) + + assert resp1.status_code == 200 + assert resp2.status_code == 200 + assert mock_rag.ask.await_count == 2 + + +def test_limiter_disabled_allows_all_requests(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "retriever.infrastructure.rate_limit.get_settings", + lambda: Settings( + _env_file=None, + rate_limit_ask="2/minute", + rate_limit_enabled=False, + ), + ) + + mock_rag, mock_repo = _make_mocks() + app = _build_app(mock_rag, mock_repo) + client = TestClient(app, raise_server_exceptions=True) + + for _ in range(6): + resp = client.post("/api/v1/ask", json={"question": "hi"}) + assert resp.status_code == 200 + + +def test_limit_is_keyed_per_user_not_globally() -> None: + mock_rag, mock_repo = _make_mocks() + app = _build_app(mock_rag, mock_repo) + client = TestClient(app, raise_server_exceptions=True) + + client.post("/api/v1/ask", json={"question": "hi"}) + client.post("/api/v1/ask", json={"question": "hi"}) + resp3 = client.post("/api/v1/ask", json={"question": "hi"}) + assert resp3.status_code == 429 + + app.dependency_overrides[require_auth] = lambda: AuthUser( + sub="11111111-2222-3333-4444-555555555555", + email="b@example.com", + is_admin=False, + ) + + resp_b = client.post("/api/v1/ask", json={"question": "hi"}) + assert resp_b.status_code == 200 + + +def test_429_passes_through_cors() -> None: + mock_rag, mock_repo = _make_mocks() + app = _build_app(mock_rag, mock_repo) + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + client = TestClient(app, raise_server_exceptions=True) + + headers = {"Origin": "http://localhost:5173"} + client.post("/api/v1/ask", json={"question": "hi"}, headers=headers) + client.post("/api/v1/ask", json={"question": "hi"}, headers=headers) + resp = client.post("/api/v1/ask", json={"question": "hi"}, headers=headers) + + assert resp.status_code == 429 + assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173" + + +def test_key_func_prefers_state_key_and_falls_back_to_host() -> None: + req = Request( + { + "type": "http", + "method": "POST", + "path": "/", + "headers": [], + "query_string": b"", + "client": ("1.2.3.4", 5), + } + ) + req.state.rate_limit_key = "abc" + assert rate_limit_key(req) == "abc" + + fresh_req = Request( + { + "type": "http", + "method": "POST", + "path": "/", + "headers": [], + "query_string": b"", + "client": ("1.2.3.4", 5), + } + ) + assert rate_limit_key(fresh_req) == "1.2.3.4" + + +def test_retry_after_fallback_when_no_window_state() -> None: + req = Request( + { + "type": "http", + "method": "POST", + "path": "/", + "headers": [], + "query_string": b"", + "client": ("1.2.3.4", 5), + } + ) + assert _retry_after_seconds(req) == 60 + + +def test_create_app_wires_limiter_and_handler() -> None: + from retriever.main import create_app + + app = create_app() + + assert app.state.limiter is limiter + assert RateLimitExceeded in app.exception_handlers diff --git a/services/retriever/uv.lock b/services/retriever/uv.lock index 9ca3ba2..f80598c 100644 --- a/services/retriever/uv.lock +++ b/services/retriever/uv.lock @@ -463,6 +463,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "dill" version = "0.4.1" @@ -1306,6 +1318,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, ] +[[package]] +name = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, +] + [[package]] name = "lxml" version = "6.0.2" @@ -2773,6 +2799,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, + { name = "slowapi" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "structlog" }, { name = "tenacity" }, @@ -2820,6 +2847,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.6" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" }, { name = "python-multipart", specifier = ">=0.0.18" }, + { name = "slowapi", specifier = ">=0.1.9" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "structlog", specifier = ">=24.4" }, { name = "tenacity", specifier = ">=9.0" }, @@ -3175,6 +3203,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slowapi" +version = "0.1.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/52/24527cf25a8b508926aff53350b0136561dfe86c7125f61526653666e1b2/slowapi-0.1.10.tar.gz", hash = "sha256:d320d5bc04d9f171a77fb16700faf3036d85b00f420f22924c8a225f95bd14f9", size = 13841, upload-time = "2026-06-13T11:59:31.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/8b/1d359f38706b4097d9a943bf8bd22599f537de4cbaff1e622d3e3936e164/slowapi-0.1.10-py3-none-any.whl", hash = "sha256:3acb61561dc9d687e3d3669362ff6a439de9ba44e2fed3a9c165da26b4b83e28", size = 14921, upload-time = "2026-06-13T11:59:30.485Z" }, +] + [[package]] name = "sniffio" version = "1.3.1"