Skip to content

Commit 51af434

Browse files
authored
Merge branch 'master' into ivana/migrate-asgi-event-processor
2 parents e2484bd + 7516309 commit 51af434

File tree

2 files changed

+205
-0
lines changed

2 files changed

+205
-0
lines changed

sentry_sdk/utils.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,15 +841,46 @@ def exceptions_from_error(
841841
parent_id: int = 0,
842842
source: "Optional[str]" = None,
843843
full_stack: "Optional[list[dict[str, Any]]]" = None,
844+
seen_exceptions: "Optional[list[BaseException]]" = None,
845+
seen_exception_ids: "Optional[Set[int]]" = None,
844846
) -> "Tuple[int, List[Dict[str, Any]]]":
845847
"""
846848
Creates the list of exceptions.
847849
This can include chained exceptions and exceptions from an ExceptionGroup.
848850
849851
See the Exception Interface documentation for more details:
850852
https://develop.sentry.dev/sdk/event-payloads/exception/
853+
854+
Args:
855+
exception_id (int):
856+
857+
Sequential counter for assigning ``mechanism.exception_id``
858+
to each processed exception. Is NOT the result of calling `id()` on the exception itself.
859+
860+
parent_id (int):
861+
862+
The ``mechanism.exception_id`` of the parent exception.
863+
864+
Written into ``mechanism.parent_id`` in the event payload so Sentry can
865+
reconstruct the exception tree.
866+
867+
Not to be confused with ``seen_exception_ids``, which tracks Python ``id()``
868+
values for cycle detection.
851869
"""
852870

871+
if seen_exception_ids is None:
872+
seen_exception_ids = set()
873+
874+
if seen_exceptions is None:
875+
seen_exceptions = []
876+
877+
if exc_value is not None and id(exc_value) in seen_exception_ids:
878+
return (exception_id, [])
879+
880+
if exc_value is not None:
881+
seen_exceptions.append(exc_value)
882+
seen_exception_ids.add(id(exc_value))
883+
853884
parent = single_exception_from_error_tuple(
854885
exc_type=exc_type,
855886
exc_value=exc_value,
@@ -888,6 +919,8 @@ def exceptions_from_error(
888919
exception_id=exception_id,
889920
source="__cause__",
890921
full_stack=full_stack,
922+
seen_exceptions=seen_exceptions,
923+
seen_exception_ids=seen_exception_ids,
891924
)
892925
exceptions.extend(child_exceptions)
893926

@@ -910,6 +943,8 @@ def exceptions_from_error(
910943
exception_id=exception_id,
911944
source="__context__",
912945
full_stack=full_stack,
946+
seen_exceptions=seen_exceptions,
947+
seen_exception_ids=seen_exception_ids,
913948
)
914949
exceptions.extend(child_exceptions)
915950

@@ -927,6 +962,8 @@ def exceptions_from_error(
927962
parent_id=parent_id,
928963
source="exceptions[%s]" % idx,
929964
full_stack=full_stack,
965+
seen_exceptions=seen_exceptions,
966+
seen_exception_ids=seen_exception_ids,
930967
)
931968
exceptions.extend(child_exceptions)
932969

tests/test_exceptiongroup.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)