Skip to content

fix: restore rate limiting under FastAPI 0.138 and await the async limiter - #4

Merged
alejom99 merged 2 commits into
masterfrom
fix/included-router-lookup
Sep 21, 2026
Merged

alejom99 merged 2 commits into
masterfrom
fix/included-router-lookup

Conversation

@alejom99

@alejom99 alejom99 commented Sep 21, 2026 •

Copy link
Copy Markdown

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_handler walked app.routes one level deep. Since FastAPI 0.138 (reproduced on 0.138.1 / Starlette 1.3.1), include_router no longer flattens the sub routes into the parent: app.routes holds one opaque fastapi.routing._IncludedRouter node per call, and those have no .endpoint.

So the lookup returned None, and _should_exempt treats 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"):

before   [200, 200, 200]   limit provider called 0 times
after    [200, 200, 429]   limit provider called 3 times

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=…) nested routers plain Starlette route in an included router
upstream laurentS#282 ❌ ❌ ❌
upstream laurentS#285 ✅ ✅ ❌
upstream laurentS#286 ✅ ✅ ❌
this PR ✅ ✅ ✅

laurentS#282 descends via original_router.routes, whose paths lack the prefix; its own test only passes because it puts the prefix on APIRouter(prefix=…) rather than passing it to include_router. laurentS#285 and laurentS#286 read .endpoint off the context, which is None for anything that isn't an APIRoute, 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 Limiter on its default memory:// storage.

  • _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. This stays hidden only while a custom exception handler that never calls _inject_headers is registered in place of the built-in one.
  • @limiter.limit dropped the _check_request_limit coroutine on the floor, so no limit was recorded and the request then blew up on the missing request.state.view_rate_limit. Every decorated route 500'd on every request.
  • sync_check_limits called the now-async _check_limits without awaiting, making SlowAPIMiddleware a no-op. It can't be fixed as a sync function and SlowAPIMiddleware.dispatch is already async, so it's dropped for async_check_limits. That also makes async exception handlers work under SlowAPIMiddleware, where they previously fell back to the default handler.

_inject_headers and _rate_limit_exceeded_handler are now async. Starlette accepts an async exception handler and both middlewares already dispatch on iscoroutinefunction. Sync endpoints run the coroutines through anyio.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.route was removed in Starlette 1.x (upstream #271), and test_key_style asserts against an un-awaited MemoryStorage.get.

Known issues left alone

  • Limiter() raises on the default memory:// storage — this fork always uses the async strategies, so callers must pass an async+ URI.
  • X-RateLimit-Reset carries a float epoch rather than an int (upstream #293).
  • Exempting routes by iterating app.routes has the same blind spot as the bug above: a route reached through include_router isn't there to be found.

🤖 Generated with Claude Code

https://claude.ai/code/session_012vJ6yefPQN33XAbE1mY1bK

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
alejom99 force-pushed the fix/included-router-lookup branch from b4e9ead to 57d49e6 Compare September 21, 2026 17:16
@alejom99
alejom99 merged commit 40ee2c8 into master Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants