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