Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions sentry_sdk/integrations/starlette.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import contextvars
import functools
import warnings
from collections.abc import Set
Expand Down Expand Up @@ -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")


Expand Down Expand Up @@ -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:
Expand Down
66 changes: 66 additions & 0 deletions tests/integrations/fastapi/test_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test never exercises the recursion guard code path

Medium Severity

The test claims to verify the ContextVar recursion guard, but the guard is never activated during the test. The _capture_exception function (where the guard lives) is only called when is_http_server_error is True, which requires the exception to have a status_code attribute. The test's PrimaryError has no status_code, so _capture_exception is never called, the guard is never set, and the test passes with or without the guard. If someone later removes the guard, this test would still pass, leaving the fix effectively untested.

Additional Locations (1)
Fix in Cursor Fix in Web

Loading