@@ -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
392454def 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