Fix included router rate limitingfix: handle included routers in default rate limiting - #282
Conversation
|
@15054538509 The added test uses limiter = Limiter(key_func=lambda request: request.client.host, default_limits=["1/minute"])
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware) # same result with SlowAPIASGIMiddleware
router = APIRouter()
@router.get("/t1")
async def endpoint(request: Request):
return {"ok": True}
app.include_router(router, prefix="/api")
with TestClient(app) as client:
assert [client.get("/api/t1").status_code for _ in range(2)] == [200, 429]Actual result on this PR: A two-level inner = APIRouter(prefix="/inner")
# add GET /t1 to inner
outer = APIRouter(prefix="/outer")
outer.include_router(inner)
app.include_router(outer, prefix="/api")
# GET /api/outer/inner/t1 twice -> [200, 200]The reason is that FastAPI 0.137's A minimal change that I verified locally is to prefer the effective candidates before falling back to the existing generic route/original-router lookup: def _get_nested_routes(route: BaseRoute) -> Optional[Iterable[BaseRoute]]:
effective_candidates = getattr(route, "effective_candidates", None)
if callable(effective_candidates):
return effective_candidates()
nested_routes = getattr(route, "routes", None)
if nested_routes is not None:
return nested_routes
original_router = getattr(route, "original_router", None)
return getattr(original_router, "routes", None)With that change, I got
There is also a test-environment gap: this repository's current |
Mukller
left a comment
There was a problem hiding this comment.
Triage note for the included-router cluster (#285, #286, #282): I verified all three functionally on FastAPI 0.141.1 with a uniform probe (default_limits only, SlowAPIMiddleware, decorator-free endpoint — the path where only _find_route_handler can enforce):
app.include_router(sub) -> second request status
app.include_router(sub, prefix="/sub")
master : 200,200 BYPASSED | 200,200 BYPASSED (bug confirmed)
#285 : 200,429 ENFORCED | 200,429 ENFORCED
#286 : 200,429 ENFORCED | 200,429 ENFORCED
#282 : 200,429 ENFORCED | 200,200 BYPASSED <- THIS PR: prefixed case still leaks
Repro of the remaining gap on this branch — the prefixed include still bypasses default limits:
limiter = Limiter(key_func=get_remote_address, default_limits=["1/minute"])
sub = APIRouter()
@sub.get("/inner")
def inner(): ...
app.add_middleware(SlowAPIMiddleware)
app.include_router(sub, prefix="/sub") # <- with prefix
# GET /sub/inner x2 -> 200, 200 (expected 200, 429)Likely cause: on 0.141 the lazy wrapper is _IncludedRouter, which has no .routes attribute; your _get_nested_routes falls back to original_router.routes, but for a prefixed include the nested routes' paths don't carry the prefix, so matches(scope) against the un-prefixed child scope returns NONE and recursion dies there. #285/#286 avoid it by recursing through effective_route_contexts()/_effective_candidates(), which carry per-context state.
Also worth noting: merging child_scope into the nested scope (which this PR does and others don't) is a good idea in principle — the winning candidates just need to reach the right level first.
Everything else about this PR (suite passes locally: 60 passed) is fine; it's only the prefixed-lazy case that needs one more hop.
Fix default rate limiting for FastAPI included routers.
Root cause: FastAPI 0.137+ stores
include_router()entries as_IncludedRouterproxies, soapp.routesno longer exposes a direct.endpointfor nested routes. slowapi was treating those routes as unmatched and skipping default limits.Changes:
default_limitsValidation:
pytest tests/test_fastapi_extension.py -q