Skip to content

Commit 26e9947

Browse files
ericapisaniclaude
andcommitted
test: Replace synthetic cycle test with realistic Starlette reproduction
Replace test_cyclic_exception_group_context with test_exceptiongroup_starlette_collapse that simulates the actual collapse_excgroups() pattern from Starlette. This provides a more realistic regression test for the cycle detection in exceptions_from_error() by reproducing the exact __context__ cycle that occurs in production with FastAPI and multiple BaseHTTPMiddleware instances. Remove the now-redundant synthetic __context__ cycle test since the new test covers the same code path with a more representative scenario. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cacf35c commit 26e9947

File tree

1 file changed

+101
-37
lines changed

1 file changed

+101
-37
lines changed

tests/test_exceptiongroup.py

Lines changed: 101 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -309,36 +309,58 @@ def test_simple_exception():
309309

310310

311311
@minimum_python_311
312-
def test_cyclic_exception_group_cause():
312+
def test_exceptiongroup_starlette_collapse():
313313
"""
314-
Regression test for https://github.com/getsentry/sentry-python/issues/5025
314+
Simulates the Starlette collapse_excgroups() pattern where a single-exception
315+
ExceptionGroup is caught and the inner exception is unwrapped and re-raised.
315316
316-
When using FastAPI with multiple BaseHTTPMiddleware instances, anyio wraps
317-
exceptions in ExceptionGroups.
317+
See: https://github.com/Kludex/starlette/blob/0e88e92b592bfa11fd92e331869a8d49ba34b541/starlette/_utils.py#L79-L87
318318
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:
319+
When using FastAPI with multiple BaseHTTPMiddleware instances, anyio wraps
320+
exceptions in ExceptionGroups. Starlette's collapse_excgroups() then unwraps
321+
single-exception groups and re-raises the inner exception.
322322
323-
ExceptionGroup -> .exceptions[0] -> ValueError -> __cause__ -> ExceptionGroup
323+
When re-raising the unwrapped exception, Python implicitly sets __context__
324+
on it pointing back to the ExceptionGroup (because the re-raise happens
325+
inside the except block that caught the ExceptionGroup), creating a cycle:
324326
325-
when the Sentry SDK attempts to walk through the chain of exceptions via the exceptions_from_error().
327+
ExceptionGroup -> .exceptions[0] -> ValueError -> __context__ -> ExceptionGroup
326328
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.
329+
Without cycle detection in exceptions_from_error(), this causes infinite
330+
recursion and a silent RecursionError that drops the event.
330331
"""
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
332+
exception_group = None
333+
334+
try:
335+
try:
336+
raise RuntimeError("something")
337+
except RuntimeError:
338+
raise ExceptionGroup(
339+
"nested",
340+
[
341+
ValueError(654),
342+
],
343+
)
344+
except ExceptionGroup as exc:
345+
exception_group = exc
346+
347+
# Simulate Starlette's collapse_excgroups() as seen here:
348+
# https://github.com/Kludex/starlette/blob/0e88e92b592bfa11fd92e331869a8d49ba34b541/starlette/_utils.py#L79-L87
349+
#
350+
# When an ExceptionGroup contains a single exception, collapse_excgroups
351+
# unwraps it and re-raises the inner exception. This causes Python to
352+
# implicitly set unwrapped.__context__ = ExceptionGroup (because the
353+
# re-raise happens inside the except block handling the ExceptionGroup),
354+
# creating a cycle:
355+
# exception_group -> .exceptions[0] -> ValueError -> __context__ -> exception_group
356+
unwrapped = exc.exceptions[0]
357+
try:
358+
raise unwrapped
359+
except Exception:
360+
pass
337361

338-
# When the ExceptionGroup is the top-level exception, exceptions_from_error
339-
# is called directly (not walk_exception_chain which has cycle detection).
340362
(event, _) = event_from_exception(
341-
group,
363+
exception_group,
342364
client_options={
343365
"include_local_variables": True,
344366
"include_source_context": True,
@@ -347,30 +369,68 @@ def test_cyclic_exception_group_cause():
347369
mechanism={"type": "test_suite", "handled": False},
348370
)
349371

350-
exception_values = event["exception"]["values"]
372+
values = event["exception"]["values"]
351373

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
374+
# For this test the stacktrace and the module is not important
375+
for x in values:
376+
if "stacktrace" in x:
377+
del x["stacktrace"]
378+
if "module" in x:
379+
del x["module"]
380+
381+
expected_values = [
382+
{
383+
"mechanism": {
384+
"exception_id": 2,
385+
"handled": False,
386+
"parent_id": 0,
387+
"source": "exceptions[0]",
388+
"type": "chained",
389+
},
390+
"type": "ValueError",
391+
"value": "654",
392+
},
393+
{
394+
"mechanism": {
395+
"exception_id": 1,
396+
"handled": False,
397+
"parent_id": 0,
398+
"source": "__context__",
399+
"type": "chained",
400+
},
401+
"type": "RuntimeError",
402+
"value": "something",
403+
},
404+
{
405+
"mechanism": {
406+
"exception_id": 0,
407+
"handled": False,
408+
"is_exception_group": True,
409+
"type": "test_suite",
410+
},
411+
"type": "ExceptionGroup",
412+
"value": "nested",
413+
},
414+
]
415+
416+
assert values == expected_values
357417

358418

359419
@minimum_python_311
360-
def test_cyclic_exception_group_context():
420+
def test_cyclic_exception_group_cause():
361421
"""
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
422+
Test case related to `test_exceptiongroup_starlette_collapse` above. We want to make sure that
423+
the same cyclic loop cannot happen via the __cause__ as well as the __context__
367424
"""
425+
# Construct the exact cyclic structure that anyio/Starlette creates when
426+
# an exception propagates through multiple BaseHTTPMiddleware layers.
368427
original = ValueError("original error")
369428
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
429+
original.__cause__ = group
430+
original.__suppress_context__ = True
373431

432+
# When the ExceptionGroup is the top-level exception, exceptions_from_error
433+
# is called directly (not walk_exception_chain which has cycle detection).
374434
(event, _) = event_from_exception(
375435
group,
376436
client_options={
@@ -382,6 +442,8 @@ def test_cyclic_exception_group_context():
382442
)
383443

384444
exception_values = event["exception"]["values"]
445+
446+
# Must produce a finite list of exceptions without hitting RecursionError.
385447
assert len(exception_values) >= 1
386448
exc_types = [v["type"] for v in exception_values]
387449
assert "ExceptionGroup" in exc_types
@@ -391,7 +453,9 @@ def test_cyclic_exception_group_context():
391453
@minimum_python_311
392454
def test_deeply_nested_cyclic_exception_group():
393455
"""
394-
A more complex cycle: ExceptionGroup -> ValueError -> __cause__ ->
456+
Related to the `test_exceptiongroup_starlette_collapse` test above.
457+
458+
Testing a more complex cycle: ExceptionGroup -> ValueError -> __cause__ ->
395459
ExceptionGroup (nested) -> TypeError -> __cause__ -> original ExceptionGroup
396460
"""
397461
inner_error = TypeError("inner")

0 commit comments

Comments
 (0)