From ccc861938761ccdff27be7f031196cdc6cfe63db Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 28 Apr 2026 09:51:49 -0400 Subject: [PATCH] fix(test-schemathesis): disable rate limiter in fuzz subprocess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schemathesis fires up to 3000 examples per endpoint. POST /auth/login caps at 10/5min per IP, so the second example onward returns 429 and the positive_data_acceptance check flags it as RejectedPositiveData (its allowed-status list is hardcoded in schemathesis to 2xx/401/403/404/409/5xx, so OpenAPI tweaks can't fix it). DECNET_LIMITER_ENABLED=false exists for exactly this case (see limiter.py docstring on stress/load testing). Reverts the custom_openapi shim from 5d88346 / 9b1168c — the endpoint already declares 429 in its responses= map (api_login.py:38), and the shim turned out to address a problem that wasn't there. Drop the companion test along with it. --- decnet/web/api.py | 83 ---------------------------- tests/api/test_schemathesis.py | 6 ++ tests/web/test_openapi_rate_limit.py | 50 ----------------- 3 files changed, 6 insertions(+), 133 deletions(-) delete mode 100644 tests/web/test_openapi_rate_limit.py diff --git a/decnet/web/api.py b/decnet/web/api.py index 0f1fa985..d446871c 100644 --- a/decnet/web/api.py +++ b/decnet/web/api.py @@ -258,89 +258,6 @@ if DECNET_PROFILE_REQUESTS: app.include_router(api_router, prefix="/api/v1") -def _rate_limited_endpoint_names() -> set[str]: - """Return ``{module}.{qualname}`` for every endpoint slowapi tracks. - - 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 - schema = get_openapi( - title=app.title, - version=app.version, - 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": { - "application/json": { - "schema": { - "type": "object", - "properties": {"error": {"type": "string"}}, - "required": ["error"], - } - } - }, - } - - # 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 - 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 - - -app.openapi = _custom_openapi # type: ignore[method-assign] - - @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> ORJSONResponse: """ diff --git a/tests/api/test_schemathesis.py b/tests/api/test_schemathesis.py index 938d92a3..8805471b 100644 --- a/tests/api/test_schemathesis.py +++ b/tests/api/test_schemathesis.py @@ -94,6 +94,12 @@ def start_automated_server() -> subprocess.Popen: env["DECNET_DEVELOPER"] = "true" env["DECNET_CONTRACT_TEST"] = "true" env["DECNET_JWT_SECRET"] = TEST_SECRET + # Schemathesis fires thousands of examples per endpoint; the login + # bucket (10/5min per IP) trips on the second example and turns + # every subsequent valid request into a RejectedPositiveData + # failure. Disable the limiter for the fuzz subprocess — same + # rationale as the load-testing knob in decnet/web/limiter.py. + env["DECNET_LIMITER_ENABLED"] = "false" log_dir = Path(__file__).parent.parent.parent / "logs" log_dir.mkdir(exist_ok=True) diff --git a/tests/web/test_openapi_rate_limit.py b/tests/web/test_openapi_rate_limit.py deleted file mode 100644 index cf04ae1e..00000000 --- a/tests/web/test_openapi_rate_limit.py +++ /dev/null @@ -1,50 +0,0 @@ -"""OpenAPI must advertise 429 on every slowapi-rate-limited operation. - -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, _rate_limited_endpoint_names -from fastapi.routing import APIRoute - - -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 - 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"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"]