diff --git a/decnet/web/api.py b/decnet/web/api.py index 6edd419b..0f1fa985 100644 --- a/decnet/web/api.py +++ b/decnet/web/api.py @@ -258,15 +258,32 @@ if DECNET_PROFILE_REQUESTS: app.include_router(api_router, prefix="/api/v1") -def _custom_openapi() -> dict: - """Inject the global 429 response into every operation. +def _rate_limited_endpoint_names() -> set[str]: + """Return ``{module}.{qualname}`` for every endpoint slowapi tracks. - SlowAPI middleware can short-circuit any request with 429 once the - per-route or per-IP rate limit fires, so the OpenAPI spec must - advertise 429 on every operation — otherwise schemathesis flags - legitimate rate-limit responses as schema-noncompliant. + slowapi tags each ``@limiter.limit(...)``-decorated function under + these two registries. We do NOT advertise 429 on undecorated routes: + SlowAPIMiddleware only consults the registries, so an endpoint + without a decorator can never legitimately return 429, and + documenting it would mislead clients. + """ + names: set[str] = set() + names.update(getattr(limiter, "_route_limits", {}).keys()) + names.update(getattr(limiter, "_dynamic_route_limits", {}).keys()) + return names + + +def _custom_openapi() -> dict: + """Inject 429 into the OpenAPI spec for rate-limited operations only. + + SlowAPI returns 429 from ``@limiter.limit(...)``-decorated routes + (currently just ``POST /api/v1/auth/login``). Without 429 in the + spec, schemathesis flags legitimate rate-limit responses as + status-code-nonconformant. We restrict the injection to actually + rate-limited endpoints so the spec stays honest. """ from fastapi.openapi.utils import get_openapi + from fastapi.routing import APIRoute if app.openapi_schema: return app.openapi_schema @@ -276,6 +293,12 @@ def _custom_openapi() -> dict: routes=app.routes, description=app.description, ) + + rate_limited = _rate_limited_endpoint_names() + if not rate_limited: + app.openapi_schema = schema + return schema + too_many = { "description": "Rate limit exceeded", "content": { @@ -288,13 +311,29 @@ def _custom_openapi() -> dict: } }, } - for path_item in schema.get("paths", {}).values(): + + # Build {(path, method): endpoint_qualname} from the live route table. + route_index: dict[tuple[str, str], str] = {} + for route in app.routes: + if not isinstance(route, APIRoute): + continue + endpoint = route.endpoint + # slowapi unwraps decorated functions but keeps the original + # __module__/__name__ via functools.wraps, so this matches. + qualname = f"{endpoint.__module__}.{endpoint.__name__}" + for method in route.methods or (): + route_index[(route.path, method.lower())] = qualname + + for path, path_item in schema.get("paths", {}).items(): for method, op in path_item.items(): if method.lower() not in { "get", "post", "put", "patch", "delete", "options", "head", }: continue - op.setdefault("responses", {}).setdefault("429", too_many) + qualname = route_index.get((path, method.lower())) + if qualname and qualname in rate_limited: + op.setdefault("responses", {}).setdefault("429", too_many) + app.openapi_schema = schema return schema diff --git a/tests/web/test_openapi_rate_limit.py b/tests/web/test_openapi_rate_limit.py index 87a144c0..cf04ae1e 100644 --- a/tests/web/test_openapi_rate_limit.py +++ b/tests/web/test_openapi_rate_limit.py @@ -1,24 +1,50 @@ -"""OpenAPI must advertise 429 on every operation. +"""OpenAPI must advertise 429 on every slowapi-rate-limited operation. -SlowAPI can return 429 from any rate-limited route at any time. If the -schema doesn't list it, schemathesis (and any other contract-driven -client) treats a legitimate rate-limit response as a contract violation. +Other endpoints may also advertise 429 for their own reasons (e.g. the +SSE connection cap in ``decnet.web.sse_limits``); the test does not +forbid those — it only enforces the slowapi side. """ -from decnet.web.api import app +from decnet.web.api import app, _rate_limited_endpoint_names +from fastapi.routing import APIRoute -def test_every_operation_documents_429() -> None: +def _route_qualname_index() -> dict[tuple[str, str], str]: + idx: dict[tuple[str, str], str] = {} + for route in app.routes: + if not isinstance(route, APIRoute): + continue + qn = f"{route.endpoint.__module__}.{route.endpoint.__name__}" + for method in route.methods or (): + idx[(route.path, method.lower())] = qn + return idx + + +def test_429_documented_on_rate_limited_endpoints_only() -> None: schema = app.openapi() paths = schema.get("paths", {}) assert paths, "OpenAPI schema is empty — router not mounted" + rate_limited = _rate_limited_endpoint_names() + assert rate_limited, "no @limiter.limit-decorated endpoints found" + + qualname_for = _route_qualname_index() + http_methods = {"get", "post", "put", "patch", "delete", "options", "head"} missing: list[str] = [] + for path, item in paths.items(): for method, op in item.items(): if method.lower() not in http_methods: continue - if "429" not in op.get("responses", {}): + qn = qualname_for.get((path, method.lower())) + if qn in rate_limited and "429" not in op.get("responses", {}): missing.append(f"{method.upper()} {path}") - assert not missing, f"Operations missing 429 response: {missing[:5]}" + assert not missing, f"rate-limited ops missing 429: {missing}" + + +def test_login_endpoint_documents_429() -> None: + """Sanity check the one endpoint we know is rate-limited.""" + schema = app.openapi() + op = schema["paths"]["/api/v1/auth/login"]["post"] + assert "429" in op["responses"]