Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions sentry_sdk/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,8 @@ def exceptions_from_error(
parent_id: int = 0,
source: "Optional[str]" = None,
full_stack: "Optional[list[dict[str, Any]]]" = None,
seen_exceptions: "Optional[list]" = None,
seen_exception_ids: "Optional[Set[int]]" = None,
) -> "Tuple[int, List[Dict[str, Any]]]":
"""
Creates the list of exceptions.
Expand All @@ -828,6 +830,17 @@ def exceptions_from_error(
https://develop.sentry.dev/sdk/event-payloads/exception/
"""

if seen_exception_ids is None:
seen_exceptions = []
seen_exception_ids = set()

if exc_value is not None and id(exc_value) in seen_exception_ids:
Comment thread
ericapisani marked this conversation as resolved.
return (exception_id, [])

if exc_value is not None:
seen_exceptions.append(exc_value)
Comment thread
ericapisani marked this conversation as resolved.
seen_exception_ids.add(id(exc_value))

parent = single_exception_from_error_tuple(
exc_type=exc_type,
exc_value=exc_value,
Expand Down Expand Up @@ -866,6 +879,8 @@ def exceptions_from_error(
exception_id=exception_id,
source="__cause__",
full_stack=full_stack,
seen_exceptions=seen_exceptions,
seen_exception_ids=seen_exception_ids,
)
exceptions.extend(child_exceptions)

Expand All @@ -888,6 +903,8 @@ def exceptions_from_error(
exception_id=exception_id,
source="__context__",
full_stack=full_stack,
seen_exceptions=seen_exceptions,
seen_exception_ids=seen_exception_ids,
)
exceptions.extend(child_exceptions)

Expand All @@ -905,6 +922,8 @@ def exceptions_from_error(
parent_id=parent_id,
source="exceptions[%s]" % idx,
full_stack=full_stack,
seen_exceptions=seen_exceptions,
seen_exception_ids=seen_exception_ids,
)
exceptions.extend(child_exceptions)

Expand Down
179 changes: 179 additions & 0 deletions tests/test_exceptiongroup.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,182 @@ def test_simple_exception():

exception_values = event["exception"]["values"]
assert exception_values == expected_exception_values


@minimum_python_311
def test_exceptiongroup_starlette_collapse():
"""
Simulates the Starlette collapse_excgroups() pattern where a single-exception
ExceptionGroup is caught and the inner exception is unwrapped and re-raised.

See: https://github.com/Kludex/starlette/blob/0e88e92b592bfa11fd92e331869a8d49ba34b541/starlette/_utils.py#L79-L87

When using FastAPI with multiple BaseHTTPMiddleware instances, anyio wraps
exceptions in ExceptionGroups. Starlette's collapse_excgroups() then unwraps
single-exception groups and re-raises the inner exception.

When re-raising the unwrapped exception, Python implicitly sets __context__
on it pointing back to the ExceptionGroup (because the re-raise happens
inside the except block that caught the ExceptionGroup), creating a cycle:

ExceptionGroup -> .exceptions[0] -> ValueError -> __context__ -> ExceptionGroup

Without cycle detection in exceptions_from_error(), this causes infinite
recursion and a silent RecursionError that drops the event.
"""
exception_group = None

try:
try:
raise RuntimeError("something")
except RuntimeError:
raise ExceptionGroup(
"nested",
[
ValueError(654),
],
)
except ExceptionGroup as exc:
exception_group = exc

# Simulate Starlette's collapse_excgroups() as seen here:
# https://github.com/Kludex/starlette/blob/0e88e92b592bfa11fd92e331869a8d49ba34b541/starlette/_utils.py#L79-L87
#
# When an ExceptionGroup contains a single exception, collapse_excgroups
# unwraps it and re-raises the inner exception. This causes Python to
# implicitly set unwrapped.__context__ = ExceptionGroup (because the
# re-raise happens inside the except block handling the ExceptionGroup),
# creating a cycle:
# exception_group -> .exceptions[0] -> ValueError -> __context__ -> exception_group
unwrapped = exc.exceptions[0]
try:
raise unwrapped
except Exception:
pass

(event, _) = event_from_exception(
exception_group,
client_options={
"include_local_variables": True,
"include_source_context": True,
"max_value_length": 1024,
},
mechanism={"type": "test_suite", "handled": False},
)

values = event["exception"]["values"]

# For this test the stacktrace and the module is not important
for x in values:
if "stacktrace" in x:
del x["stacktrace"]
if "module" in x:
del x["module"]

expected_values = [
{
"mechanism": {
"exception_id": 2,
"handled": False,
"parent_id": 0,
"source": "exceptions[0]",
"type": "chained",
},
"type": "ValueError",
"value": "654",
},
{
"mechanism": {
"exception_id": 1,
"handled": False,
"parent_id": 0,
"source": "__context__",
"type": "chained",
},
"type": "RuntimeError",
"value": "something",
},
{
"mechanism": {
"exception_id": 0,
"handled": False,
"is_exception_group": True,
"type": "test_suite",
},
"type": "ExceptionGroup",
"value": "nested",
},
]

assert values == expected_values


@minimum_python_311
def test_cyclic_exception_group_cause():
"""
Test case related to `test_exceptiongroup_starlette_collapse` above. We want to make sure that
the same cyclic loop cannot happen via the __cause__ as well as the __context__
"""
# Construct the exact cyclic structure that anyio/Starlette creates when
# an exception propagates through multiple BaseHTTPMiddleware layers.
original = ValueError("original error")
group = ExceptionGroup("unhandled errors in a TaskGroup", [original])
original.__cause__ = group
original.__suppress_context__ = True

# When the ExceptionGroup is the top-level exception, exceptions_from_error
# is called directly (not walk_exception_chain which has cycle detection).
(event, _) = event_from_exception(
group,
client_options={
"include_local_variables": True,
"include_source_context": True,
"max_value_length": 1024,
},
mechanism={"type": "test_suite", "handled": False},
)

exception_values = event["exception"]["values"]

# Must produce a finite list of exceptions without hitting RecursionError.
assert len(exception_values) >= 1
exc_types = [v["type"] for v in exception_values]
assert "ExceptionGroup" in exc_types
assert "ValueError" in exc_types


@minimum_python_311
def test_deeply_nested_cyclic_exception_group():
"""
Related to the `test_exceptiongroup_starlette_collapse` test above.

Testing a more complex cycle: ExceptionGroup -> ValueError -> __cause__ ->
ExceptionGroup (nested) -> TypeError -> __cause__ -> original ExceptionGroup
"""
inner_error = TypeError("inner")
outer_error = ValueError("outer")
inner_group = ExceptionGroup("inner group", [inner_error])
outer_group = ExceptionGroup("outer group", [outer_error])

# Create a cycle spanning two ExceptionGroups
outer_error.__cause__ = inner_group
outer_error.__suppress_context__ = True
inner_error.__cause__ = outer_group
inner_error.__suppress_context__ = True

(event, _) = event_from_exception(
outer_group,
client_options={
"include_local_variables": True,
"include_source_context": True,
"max_value_length": 1024,
},
mechanism={"type": "test_suite", "handled": False},
)

exception_values = event["exception"]["values"]
assert len(exception_values) >= 1
exc_types = [v["type"] for v in exception_values]
assert "ExceptionGroup" in exc_types
assert "ValueError" in exc_types
assert "TypeError" in exc_types
Loading