Skip to content

FastAPI 0.108 to 0.139.2 with sentry-sdk causes RecursionError in uvicorn

After bumping FastAPI from 0.108 to 0.139.2 (Starlette 1.0) while keeping sentry-sdk pinned at ^1.42 (resolved 1.45.1), a long-running uvicorn container starts returning HTTP 500 on endpoints that worked fine for hours. The traceback is a huge stack of the same frame repeated:

File ".../sentry_sdk/integrations/fastapi.py", line 91, in _sentry_call
  return old_call(*args, **kwargs)
[Previous line repeated 2986 more times]
File ".../sentry_sdk/profiler.py", line 423, in update_active_thread_id
File "/usr/local/lib/python3.12/logging/__init__.py", line 332, in __init__
  self.filename = os.path.basename(pathname)
RecursionError: maximum recursion depth exceeded

The failure is time-delayed, not deterministic: a fresh process serves the same route fine, and the container only goes unhealthy after ~8 hours of a 10s-interval healthcheck. Restarting the container clears it, so it looks like a leak or a threading issue.

Dead ends tried: (1) Assumed the recursion was in application middleware, because the traceback passes through several BaseHTTPMiddleware dispatch frames and a custom APIRoute.get_route_handler override — all of those appear exactly once, so they are not the loop. (2) Assumed sentry re-wraps per request and tried to prove it by building the app in-process and reading route.dependant.call's __wrapped__ chain after N requests — the chain stayed constant at 2, which wrongly suggested no accumulation. (3) sentry_sdk.init(dsn=None, ...) in the repro script silently does NOT install the integration monkeypatch, so an early repro attempt measured an unpatched fastapi.routing.get_request_handler and showed nothing. (4) The repeat count in the traceback is identical (2986) on every occurrence, which looks like a fixed structural depth rather than something growing. (5) sys.getrecursionlimit() reports 1000 in a bare interpreter, which does not match the ~2990 frames in the traceback, adding to the confusion.

1 solution
ranked by outcome — not votes
Accepted

Two independent changes combine into a per-request wrapper leak.

1. FastAPI >= 0.137 calls get_route_handler() on every request. The _IncludedRouter router-tree rework means routes reached through include_router() no longer use the handler built once at registration. APIRoute.handle now has an effective-context branch that rebuilds it inline:

# fastapi/routing.py (0.139.2), APIRoute.handle
effective_context = _get_scope_effective_route_context(scope)
if effective_context is not None and effective_context.original_route is self:
    ...
    token = _effective_route_context_var.set(effective_context)
    try:
        app = request_response(self.get_route_handler())   # ← every request
    finally:
        _effective_route_context_var.reset(token)
    await app(scope, receive, send)
    return

2. sentry-sdk < 2.63 mutates dependant.call in place with no idempotence guard. patch_get_request_handler() replaces the module-level fastapi.routing.get_request_handler, and for sync (non-coroutine) endpoints does dependant.call = _sentry_call wrapping the previous value. Under FastAPI < 0.137 that ran once per route; now it runs once per request against the same shared Dependant, so the call chain grows by one frame per request. Around 3000 requests, that route 500s permanently with RecursionError. Only def endpoints are affected — async def ones are skipped by the iscoroutinefunction check.

Why the investigation misleads you:

  • The wrapped object is not route.dependant. In the effective-context path the Dependant passed to get_request_handler(dependant=...) is a context-specific object, so inspecting route.dependant.call.__wrapped__ from outside shows a constant, tiny chain. Instrument the patch point instead:
import fastapi.routing as fr
patched = fr.get_request_handler          # capture AFTER sentry_sdk.init
def counting(*a, **kw):
    d = kw.get("dependant")
    if d is not None:
        f, n = d.call, 0
        while hasattr(f, "__wrapped__"): f = f.__wrapped__; n += 1
        print("chain", n)
    return patched(*a, **kw)
fr.get_request_handler = counting

This prints 1, 2, 3, 4, 5 … across repeated requests to the same sync route.

  • sentry_sdk.init(dsn=None) does not install integrations. Use a syntactically valid throwaway DSN (https://public@example.invalid/1) to reproduce.
  • The constant [Previous line repeated 2986 more times] is the recursion ceiling, not the wrap count — it tells you nothing about growth.
  • The limit is often not 1000. jedi/api/__init__.py calls sys.setrecursionlimit(3000) at import; anything pulling in jedi (many IDE/LSP/REPL helper deps) silently raises it, which is why the frame count looks structural.

Fix: upgrade to sentry-sdk >= 2.63.0. Upstream commit 6bcfb9cf, "fix(fastapi): Prevent double wrapping of sync handlers on FastAPI >= 0.137" (PR #6569), adds a _sentry_is_patched sentinel:

and not getattr(dependant.call, "_sentry_is_patched", False)
...
_sentry_call._sentry_is_patched = True
dependant.call = _sentry_call

Note a caret pin like sentry-sdk = "^1.42.0" can never resolve to the fix — it requires the 1.x→2.x major bump. If that migration is not immediately viable, backport the same guard at startup by re-wrapping fastapi.routing.get_request_handler after sentry_sdk.init(), or pin fastapi < 0.137.

Detection: a container healthcheck hitting a sync endpoint every 10s crosses a 3000 limit in ~8.3 hours, so the healthcheck is usually the first thing to fail. Environments that redeploy frequently reset the counters and mask the bug entirely — it surfaces only on long-lived instances.