Skip to content

Commit cacf35c

Browse files
ericapisaniclaude
andcommitted
fix: Add cycle detection to exceptions_from_error
When using FastAPI with multiple BaseHTTPMiddleware instances, anyio wraps exceptions in ExceptionGroups. When Starlette unpacks and reraises these, it creates cyclic references (ExceptionGroup -> ValueError -> __cause__ -> ExceptionGroup) that cause infinite recursion in exceptions_from_error(), silently dropping the event. Track seen exception object ids during recursive walks through __cause__, __context__, and ExceptionGroup.exceptions to break cycles. Fixes GH-5025 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a5d04d6 commit cacf35c

File tree

2 files changed

+134
-0
lines changed

2 files changed

+134
-0
lines changed

sentry_sdk/utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,8 @@ def exceptions_from_error(
819819
parent_id: int = 0,
820820
source: "Optional[str]" = None,
821821
full_stack: "Optional[list[dict[str, Any]]]" = None,
822+
seen_exceptions: "Optional[list]" = None,
823+
seen_exception_ids: "Optional[Set[int]]" = None,
822824
) -> "Tuple[int, List[Dict[str, Any]]]":
823825
"""
824826
Creates the list of exceptions.
@@ -828,6 +830,17 @@ def exceptions_from_error(
828830
https://develop.sentry.dev/sdk/event-payloads/exception/
829831
"""
830832

833+
if seen_exception_ids is None:
834+
seen_exceptions = []
835+
seen_exception_ids = set()
836+
837+
if exc_value is not None and id(exc_value) in seen_exception_ids:
838+
return (exception_id, [])
839+
840+
if exc_value is not None:
841+
seen_exceptions.append(exc_value)
842+
seen_exception_ids.add(id(exc_value))
843+
831844
parent = single_exception_from_error_tuple(
832845
exc_type=exc_type,
833846
exc_value=exc_value,
@@ -866,6 +879,8 @@ def exceptions_from_error(
866879
exception_id=exception_id,
867880
source="__cause__",
868881
full_stack=full_stack,
882+
seen_exceptions=seen_exceptions,
883+
seen_exception_ids=seen_exception_ids,
869884
)
870885
exceptions.extend(child_exceptions)
871886

@@ -888,6 +903,8 @@ def exceptions_from_error(
888903
exception_id=exception_id,
889904
source="__context__",
890905
full_stack=full_stack,
906+
seen_exceptions=seen_exceptions,
907+
seen_exception_ids=seen_exception_ids,
891908
)
892909
exceptions.extend(child_exceptions)
893910

@@ -905,6 +922,8 @@ def exceptions_from_error(
905922
parent_id=parent_id,
906923
source="exceptions[%s]" % idx,
907924
full_stack=full_stack,
925+
seen_exceptions=seen_exceptions,
926+
seen_exception_ids=seen_exception_ids,
908927
)
909928
exceptions.extend(child_exceptions)
910929

tests/test_exceptiongroup.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,118 @@ def test_simple_exception():
306306

307307
exception_values = event["exception"]["values"]
308308
assert exception_values == expected_exception_values
309+
310+
311+
@minimum_python_311
312+
def test_cyclic_exception_group_cause():
313+
"""
314+
Regression test for https://github.com/getsentry/sentry-python/issues/5025
315+
316+
When using FastAPI with multiple BaseHTTPMiddleware instances, anyio wraps
317+
exceptions in ExceptionGroups.
318+
319+
When a single exception is unpacked from the ExceptionGroup by Starlette and reraised in the `collapse_excgroups()` method
320+
(see https://github.com/Kludex/starlette/blob/0e88e92b592bfa11fd92e331869a8d49ba34b541/starlette/_utils.py#L79-L87)
321+
it causes the following situation to happen:
322+
323+
ExceptionGroup -> .exceptions[0] -> ValueError -> __cause__ -> ExceptionGroup
324+
325+
when the Sentry SDK attempts to walk through the chain of exceptions via the exceptions_from_error().
326+
327+
This is because `exceptions_from_error` walks both __cause__/__context__ and
328+
ExceptionGroup.exceptions recursively without cycle detection, causing
329+
infinite recursion and a silent RecursionError that drops the event.
330+
"""
331+
# Construct the exact cyclic structure that anyio/Starlette creates when
332+
# an exception propagates through multiple BaseHTTPMiddleware layers.
333+
original = ValueError("original error")
334+
group = ExceptionGroup("unhandled errors in a TaskGroup", [original])
335+
original.__cause__ = group
336+
original.__suppress_context__ = True
337+
338+
# When the ExceptionGroup is the top-level exception, exceptions_from_error
339+
# is called directly (not walk_exception_chain which has cycle detection).
340+
(event, _) = event_from_exception(
341+
group,
342+
client_options={
343+
"include_local_variables": True,
344+
"include_source_context": True,
345+
"max_value_length": 1024,
346+
},
347+
mechanism={"type": "test_suite", "handled": False},
348+
)
349+
350+
exception_values = event["exception"]["values"]
351+
352+
# Must produce a finite list of exceptions without hitting RecursionError.
353+
assert len(exception_values) >= 1
354+
exc_types = [v["type"] for v in exception_values]
355+
assert "ExceptionGroup" in exc_types
356+
assert "ValueError" in exc_types
357+
358+
359+
@minimum_python_311
360+
def test_cyclic_exception_group_context():
361+
"""
362+
Same as test_cyclic_exception_group_cause (see above) but the cycle goes through
363+
__context__ instead of __cause__ .
364+
365+
This is the more likely scenario to occur in production because __context__ is set implicitly by
366+
Python while __cause__ is set explicitly when "raise X from Y" is written in the code
367+
"""
368+
original = ValueError("original error")
369+
group = ExceptionGroup("unhandled errors in a TaskGroup", [original])
370+
original.__context__ = group
371+
# __suppress_context__ = False so that exceptions_from_error follows __context__
372+
original.__suppress_context__ = False
373+
374+
(event, _) = event_from_exception(
375+
group,
376+
client_options={
377+
"include_local_variables": True,
378+
"include_source_context": True,
379+
"max_value_length": 1024,
380+
},
381+
mechanism={"type": "test_suite", "handled": False},
382+
)
383+
384+
exception_values = event["exception"]["values"]
385+
assert len(exception_values) >= 1
386+
exc_types = [v["type"] for v in exception_values]
387+
assert "ExceptionGroup" in exc_types
388+
assert "ValueError" in exc_types
389+
390+
391+
@minimum_python_311
392+
def test_deeply_nested_cyclic_exception_group():
393+
"""
394+
A more complex cycle: ExceptionGroup -> ValueError -> __cause__ ->
395+
ExceptionGroup (nested) -> TypeError -> __cause__ -> original ExceptionGroup
396+
"""
397+
inner_error = TypeError("inner")
398+
outer_error = ValueError("outer")
399+
inner_group = ExceptionGroup("inner group", [inner_error])
400+
outer_group = ExceptionGroup("outer group", [outer_error])
401+
402+
# Create a cycle spanning two ExceptionGroups
403+
outer_error.__cause__ = inner_group
404+
outer_error.__suppress_context__ = True
405+
inner_error.__cause__ = outer_group
406+
inner_error.__suppress_context__ = True
407+
408+
(event, _) = event_from_exception(
409+
outer_group,
410+
client_options={
411+
"include_local_variables": True,
412+
"include_source_context": True,
413+
"max_value_length": 1024,
414+
},
415+
mechanism={"type": "test_suite", "handled": False},
416+
)
417+
418+
exception_values = event["exception"]["values"]
419+
assert len(exception_values) >= 1
420+
exc_types = [v["type"] for v in exception_values]
421+
assert "ExceptionGroup" in exc_types
422+
assert "ValueError" in exc_types
423+
assert "TypeError" in exc_types

0 commit comments

Comments
 (0)