@@ -306,3 +306,171 @@ 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_exceptiongroup_starlette_collapse ():
313+ """
314+ Simulates the Starlette collapse_excgroups() pattern where a single-exception
315+ ExceptionGroup is caught and the inner exception is unwrapped and re-raised.
316+
317+ See: https://github.com/Kludex/starlette/blob/0e88e92b592bfa11fd92e331869a8d49ba34b541/starlette/_utils.py#L79-L87
318+
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.
322+
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:
326+
327+ ExceptionGroup -> .exceptions[0] -> ValueError -> __context__ -> ExceptionGroup
328+
329+ Without cycle detection in exceptions_from_error(), this causes infinite
330+ recursion and a silent RecursionError that drops the event.
331+ """
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+ unwrapped = exc .exceptions [0 ]
347+ try :
348+ raise unwrapped
349+ except Exception :
350+ pass
351+
352+ (event , _ ) = event_from_exception (
353+ exception_group ,
354+ client_options = {
355+ "include_local_variables" : True ,
356+ "include_source_context" : True ,
357+ "max_value_length" : 1024 ,
358+ },
359+ mechanism = {"type" : "test_suite" , "handled" : False },
360+ )
361+
362+ values = event ["exception" ]["values" ]
363+
364+ # For this test the stacktrace and the module is not important
365+ for x in values :
366+ if "stacktrace" in x :
367+ del x ["stacktrace" ]
368+ if "module" in x :
369+ del x ["module" ]
370+
371+ expected_values = [
372+ {
373+ "mechanism" : {
374+ "exception_id" : 2 ,
375+ "handled" : False ,
376+ "parent_id" : 0 ,
377+ "source" : "exceptions[0]" ,
378+ "type" : "chained" ,
379+ },
380+ "type" : "ValueError" ,
381+ "value" : "654" ,
382+ },
383+ {
384+ "mechanism" : {
385+ "exception_id" : 1 ,
386+ "handled" : False ,
387+ "parent_id" : 0 ,
388+ "source" : "__context__" ,
389+ "type" : "chained" ,
390+ },
391+ "type" : "RuntimeError" ,
392+ "value" : "something" ,
393+ },
394+ {
395+ "mechanism" : {
396+ "exception_id" : 0 ,
397+ "handled" : False ,
398+ "is_exception_group" : True ,
399+ "type" : "test_suite" ,
400+ },
401+ "type" : "ExceptionGroup" ,
402+ "value" : "nested" ,
403+ },
404+ ]
405+
406+ assert values == expected_values
407+
408+
409+ @minimum_python_311
410+ def test_cyclic_exception_group_cause ():
411+ """
412+ Test case related to `test_exceptiongroup_starlette_collapse` above. We want to make sure that
413+ the same cyclic loop cannot happen via the __cause__ as well as the __context__
414+ """
415+
416+ original = ValueError ("original error" )
417+ group = ExceptionGroup ("unhandled errors in a TaskGroup" , [original ])
418+ original .__cause__ = group
419+ original .__suppress_context__ = True
420+
421+ # When the ExceptionGroup is the top-level exception, exceptions_from_error
422+ # is called directly (not walk_exception_chain which has cycle detection).
423+ (event , _ ) = event_from_exception (
424+ group ,
425+ client_options = {
426+ "include_local_variables" : True ,
427+ "include_source_context" : True ,
428+ "max_value_length" : 1024 ,
429+ },
430+ mechanism = {"type" : "test_suite" , "handled" : False },
431+ )
432+
433+ exception_values = event ["exception" ]["values" ]
434+
435+ # Must produce a finite list of exceptions without hitting RecursionError.
436+ assert len (exception_values ) >= 1
437+ exc_types = [v ["type" ] for v in exception_values ]
438+ assert "ExceptionGroup" in exc_types
439+ assert "ValueError" in exc_types
440+
441+
442+ @minimum_python_311
443+ def test_deeply_nested_cyclic_exception_group ():
444+ """
445+ Related to the `test_exceptiongroup_starlette_collapse` test above.
446+
447+ Testing a more complex cycle: ExceptionGroup -> ValueError -> __cause__ ->
448+ ExceptionGroup (nested) -> TypeError -> __cause__ -> original ExceptionGroup
449+ """
450+ inner_error = TypeError ("inner" )
451+ outer_error = ValueError ("outer" )
452+ inner_group = ExceptionGroup ("inner group" , [inner_error ])
453+ outer_group = ExceptionGroup ("outer group" , [outer_error ])
454+
455+ # Create a cycle spanning two ExceptionGroups
456+ outer_error .__cause__ = inner_group
457+ outer_error .__suppress_context__ = True
458+ inner_error .__cause__ = outer_group
459+ inner_error .__suppress_context__ = True
460+
461+ (event , _ ) = event_from_exception (
462+ outer_group ,
463+ client_options = {
464+ "include_local_variables" : True ,
465+ "include_source_context" : True ,
466+ "max_value_length" : 1024 ,
467+ },
468+ mechanism = {"type" : "test_suite" , "handled" : False },
469+ )
470+
471+ exception_values = event ["exception" ]["values" ]
472+ assert len (exception_values ) >= 1
473+ exc_types = [v ["type" ] for v in exception_values ]
474+ assert "ExceptionGroup" in exc_types
475+ assert "ValueError" in exc_types
476+ assert "TypeError" in exc_types
0 commit comments