diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 0b797ebcde..0fd3e904cb 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -1,4 +1,5 @@ import asyncio +import contextvars import functools import warnings from collections.abc import Set @@ -76,6 +77,12 @@ _DEFAULT_TRANSACTION_NAME = "generic Starlette request" +# ContextVar-based recursion guard: prevents Sentry from re-entering its own +# exception handler when an exception is raised inside it (fixes infinite loop). +_sentry_exception_handling = contextvars.ContextVar( + "_sentry_exception_handling", default=False +) + TRANSACTION_STYLE_VALUES = ("endpoint", "url") @@ -216,13 +223,23 @@ async def _sentry_send(*args: "Any", **kwargs: "Any") -> "Any": @ensure_integration_enabled(StarletteIntegration) def _capture_exception(exception: BaseException, handled: "Any" = False) -> None: - event, hint = event_from_exception( - exception, - client_options=sentry_sdk.get_client().options, - mechanism={"type": StarletteIntegration.identifier, "handled": handled}, - ) + # Recursion guard: if we are already inside Sentry's exception handler + # (e.g. because the user's handler raised another exception), bail out + # immediately to avoid an infinite capture loop (see issue #5025). + if _sentry_exception_handling.get(): + return + + token = _sentry_exception_handling.set(True) + try: + event, hint = event_from_exception( + exception, + client_options=sentry_sdk.get_client().options, + mechanism={"type": StarletteIntegration.identifier, "handled": handled}, + ) - sentry_sdk.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) + finally: + _sentry_exception_handling.reset(token) def patch_exception_middleware(middleware_class: "Any") -> None: diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 005189f00c..8ae6db5a61 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -760,3 +760,69 @@ async def _error(): found = True assert found, "No event with exception found" + + +@pytest.mark.asyncio +async def test_recursion_guard_on_exception_in_exception_handler( + sentry_init, capture_events +): + """ + Regression test for https://github.com/getsentry/sentry-python/issues/5025 + + When a FastAPI/Starlette exception handler itself raises an exception, + Sentry's patched exception handler must not re-enter _capture_exception + recursively. Without the ContextVar guard this caused an infinite loop. + """ + sentry_init( + integrations=[StarletteIntegration(), FastApiIntegration()], + traces_sample_rate=1.0, + ) + + events = capture_events() + + app = FastAPI() + + class PrimaryError(Exception): + pass + + class HandlerError(Exception): + pass + + @app.exception_handler(PrimaryError) + async def _primary_error_handler(request: Request, exc: PrimaryError): + # Simulate a secondary exception raised from inside the user's handler. + # Without the recursion guard this would trigger _capture_exception + # again, which would call this handler again, causing infinite recursion. + raise HandlerError("secondary error raised inside exception handler") + + @app.get("/trigger") + async def _trigger(): + raise PrimaryError("original error") + + client = TestClient(app, raise_server_exceptions=False) + # The request must complete (with a 500) rather than hanging or crashing + # the test process with a RecursionError. + response = client.get("/trigger") + assert response.status_code == 500 + + # The original PrimaryError must have been captured exactly once. + captured_exceptions = [ + e for e in events if "exception" in e + ] + primary_errors_captured = [ + e for e in captured_exceptions + if any( + v.get("type") == "PrimaryError" + for v in e["exception"].get("values", []) + ) + ] + assert len(primary_errors_captured) >= 1, ( + "Expected PrimaryError to be captured by Sentry" + ) + + # Crucially: Sentry must NOT have captured the same exception repeatedly. + # A recursion bug would fill the event list with many duplicate events. + assert len(captured_exceptions) <= 5, ( + "Too many exception events captured — possible recursion: %d events" + % len(captured_exceptions) + )