fix: restore rate limiting under FastAPI 0.138 and await the async limiter - #4
Merged
Merged
Conversation
Since FastAPI 0.138, `include_router` no longer copies the sub routes into the parent app: `app.routes` holds one opaque `fastapi.routing._IncludedRouter` node per `include_router` call. `_find_route_handler` only looked one level deep and those nodes have no `endpoint`, so it returned None for every included route. `_should_exempt` treats a missing handler as exempt, which silently disabled rate limiting (default and application limits alike) for the whole app. Expand such a node into its effective, prefix-aware route contexts before matching, and read the endpoint off the route each context was built from. The lookup is duck-typed, so plain Starlette apps and older FastAPI versions are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJ6yefPQN33XAbE1mY1bK
Making the limiter async (8104a17) left three call sites calling coroutines without awaiting them. Each one fails in a different way, and none of them is caught by the test suite, which cannot build a `Limiter` at all on its default `memory://` storage. - `Limiter._inject_headers` read window stats straight off the async strategy, so `1 + window_stats[0]` raised on a coroutine. The bare `except` then marked the storage dead and re-raised: with `headers_enabled`, every breached request returned 500 instead of 429 and permanently degraded the limiter to its in-memory fallback. It is now async, as `_inject_asgi_headers` already was, and `_rate_limit_exceeded_handler` awaits it. Starlette accepts an async exception handler, and both middlewares already dispatch on `iscoroutinefunction`. - The `@limiter.limit` decorator dropped the `_check_request_limit` coroutine on the floor, so no limit was ever recorded and the request then blew up on the missing `request.state.view_rate_limit`. Async endpoints now await it; sync endpoints run it through `anyio.from_thread.run`, since Starlette executes them in an anyio worker thread. That hop costs ~130us, so both wrappers now skip the header injection when headers are disabled, where it was a no-op anyway: a decorated sync endpoint pays one hop per request rather than two, and neither wrapper reaches for `view_rate_limit` when it has no use for it. - `sync_check_limits` called the now-async `_check_limits` without awaiting, which made `SlowAPIMiddleware` a no-op. It cannot be fixed as a sync function and `SlowAPIMiddleware.dispatch` is already async, so it is dropped in favour of `async_check_limits`. That also makes async exception handlers work under `SlowAPIMiddleware`, where they previously fell back to the default handler. The test fixtures now ask for `async+memory://`, which this fork requires, so the suite actually exercises this code: 7 passing before, 88 now. The remaining 24 failures are unrelated test rot — `@app.route` was removed in Starlette 1.x (laurentS#271) and `test_key_style` asserts against an un-awaited `MemoryStorage.get`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJ6yefPQN33XAbE1mY1bK
alejom99
force-pushed
the
fix/included-router-lookup
branch
from
September 21, 2026 17:16
b4e9ead to
57d49e6
Compare
garymardell
approved these changes
Sep 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rate limiting is silently off under FastAPI 0.138: the middleware treats every request as exempt. This fixes that, plus the three un-awaited coroutines that would have turned the restored limits into 500s.
1. Included routes were never rate limited
_find_route_handlerwalkedapp.routesone level deep. Since FastAPI 0.138 (reproduced on 0.138.1 / Starlette 1.3.1),include_routerno longer flattens the sub routes into the parent:app.routesholds one opaquefastapi.routing._IncludedRouternode per call, and those have no.endpoint.So the lookup returned
None, and_should_exempttreats a missing handler as exempt. It fails open and silently — no exception, no log line, the limiter just never rejects anything. Routes declared directly on the app keep working, which makes a partially limited app look healthy.Measured, 2/minute default limit, three requests to a route added through
include_router(router, prefix="/v0"):The fix expands such a node into its effective, prefix-aware route contexts before matching, and reads the endpoint off the route each context was built from. It is duck-typed, so plain Starlette apps and older FastAPI behave exactly as before.
Upstream status
Upstream has no fix.
master(d3442b2) is the released 0.1.10 and still has the broken lookup; issue #281 is open since July with three unmerged PRs. I tested all three against this branch's tests:include_router(prefix=…)laurentS#282 descends via
original_router.routes, whose paths lack the prefix; its own test only passes because it puts the prefix onAPIRouter(prefix=…)rather than passing it toinclude_router. laurentS#285 and laurentS#286 read.endpointoff the context, which isNonefor anything that isn't anAPIRoute, so plain Starlette routes inside an included router stay silently unlimited. laurentS#286 additionally leans on_effective_candidates, a private cache field, instead of the accessor that fills it.2. Three un-awaited coroutines
Making the limiter async (8104a17) left three call sites calling coroutines without awaiting them. None is caught by the test suite, which can't even construct a
Limiteron its defaultmemory://storage._inject_headersread window stats straight off the async strategy, so1 + window_stats[0]raised on a coroutine. The bareexceptthen marked the storage dead and re-raised: withheaders_enabled, every breached request returned 500 instead of 429 and permanently degraded the limiter to its in-memory fallback. This stays hidden only while a custom exception handler that never calls_inject_headersis registered in place of the built-in one.@limiter.limitdropped the_check_request_limitcoroutine on the floor, so no limit was recorded and the request then blew up on the missingrequest.state.view_rate_limit. Every decorated route 500'd on every request.sync_check_limitscalled the now-async_check_limitswithout awaiting, makingSlowAPIMiddlewarea no-op. It can't be fixed as a sync function andSlowAPIMiddleware.dispatchis already async, so it's dropped forasync_check_limits. That also makes async exception handlers work underSlowAPIMiddleware, where they previously fell back to the default handler._inject_headersand_rate_limit_exceeded_handlerare now async. Starlette accepts an async exception handler and both middlewares already dispatch oniscoroutinefunction. Sync endpoints run the coroutines throughanyio.from_thread.run, since Starlette executes them in an anyio worker thread.Tests
Seven new tests in
tests/test_fastapi_extension.py, nine cases with the middleware parametrisation: included router, nested, direct, plain Starlette route, exempt, unknown path, and the 429-with-headers path. Six of the nine fail on the unfixed code; the other three are controls that must keep passing.The fixtures now ask for
async+memory://, which this fork requires, so the existing suite actually runs for the first time since the async change: 7 passing before this branch, 88 after.The 24 remaining failures are pre-existing test rot, unrelated to these changes:
@app.routewas removed in Starlette 1.x (upstream #271), andtest_key_styleasserts against an un-awaitedMemoryStorage.get.Known issues left alone
Limiter()raises on the defaultmemory://storage — this fork always uses the async strategies, so callers must pass anasync+URI.X-RateLimit-Resetcarries a float epoch rather than an int (upstream #293).app.routeshas the same blind spot as the bug above: a route reached throughinclude_routerisn't there to be found.🤖 Generated with Claude Code
https://claude.ai/code/session_012vJ6yefPQN33XAbE1mY1bK