From cacf35c4c8744edb841c5bcc02cc14bf86a8ad95 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 25 Mar 2026 17:10:47 +0100 Subject: [PATCH 1/5] fix: Add cycle detection to exceptions_from_error When using FastAPI with multiple BaseHTTPMiddleware instances, anyio wraps exceptions in ExceptionGroups. When Starlette unpacks and reraises these, it creates cyclic references (ExceptionGroup -> ValueError -> __cause__ -> ExceptionGroup) that cause infinite recursion in exceptions_from_error(), silently dropping the event. Track seen exception object ids during recursive walks through __cause__, __context__, and ExceptionGroup.exceptions to break cycles. Fixes GH-5025 Co-Authored-By: Claude Opus 4.6 (1M context) --- sentry_sdk/utils.py | 19 ++++++ tests/test_exceptiongroup.py | 115 +++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 4dfd36d17c..a100fbfe68 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -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. @@ -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: + return (exception_id, []) + + if exc_value is not None: + seen_exceptions.append(exc_value) + seen_exception_ids.add(id(exc_value)) + parent = single_exception_from_error_tuple( exc_type=exc_type, exc_value=exc_value, @@ -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) @@ -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) @@ -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) diff --git a/tests/test_exceptiongroup.py b/tests/test_exceptiongroup.py index 4c7afc58eb..cf675e054f 100644 --- a/tests/test_exceptiongroup.py +++ b/tests/test_exceptiongroup.py @@ -306,3 +306,118 @@ def test_simple_exception(): exception_values = event["exception"]["values"] assert exception_values == expected_exception_values + + +@minimum_python_311 +def test_cyclic_exception_group_cause(): + """ + Regression test for https://github.com/getsentry/sentry-python/issues/5025 + + When using FastAPI with multiple BaseHTTPMiddleware instances, anyio wraps + exceptions in ExceptionGroups. + + When a single exception is unpacked from the ExceptionGroup by Starlette and reraised in the `collapse_excgroups()` method + (see https://github.com/Kludex/starlette/blob/0e88e92b592bfa11fd92e331869a8d49ba34b541/starlette/_utils.py#L79-L87) + it causes the following situation to happen: + + ExceptionGroup -> .exceptions[0] -> ValueError -> __cause__ -> ExceptionGroup + + when the Sentry SDK attempts to walk through the chain of exceptions via the exceptions_from_error(). + + This is because `exceptions_from_error` walks both __cause__/__context__ and + ExceptionGroup.exceptions recursively without cycle detection, causing + infinite recursion and a silent RecursionError that drops the event. + """ + # 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_cyclic_exception_group_context(): + """ + Same as test_cyclic_exception_group_cause (see above) but the cycle goes through + __context__ instead of __cause__ . + + This is the more likely scenario to occur in production because __context__ is set implicitly by + Python while __cause__ is set explicitly when "raise X from Y" is written in the code + """ + original = ValueError("original error") + group = ExceptionGroup("unhandled errors in a TaskGroup", [original]) + original.__context__ = group + # __suppress_context__ = False so that exceptions_from_error follows __context__ + original.__suppress_context__ = False + + (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"] + 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(): + """ + 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 From 26e99472b498471cc7c38a9a8e83a669d420efa9 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 26 Mar 2026 11:39:27 +0100 Subject: [PATCH 2/5] test: Replace synthetic cycle test with realistic Starlette reproduction Replace test_cyclic_exception_group_context with test_exceptiongroup_starlette_collapse that simulates the actual collapse_excgroups() pattern from Starlette. This provides a more realistic regression test for the cycle detection in exceptions_from_error() by reproducing the exact __context__ cycle that occurs in production with FastAPI and multiple BaseHTTPMiddleware instances. Remove the now-redundant synthetic __context__ cycle test since the new test covers the same code path with a more representative scenario. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_exceptiongroup.py | 138 +++++++++++++++++++++++++---------- 1 file changed, 101 insertions(+), 37 deletions(-) diff --git a/tests/test_exceptiongroup.py b/tests/test_exceptiongroup.py index cf675e054f..48bc2e4c83 100644 --- a/tests/test_exceptiongroup.py +++ b/tests/test_exceptiongroup.py @@ -309,36 +309,58 @@ def test_simple_exception(): @minimum_python_311 -def test_cyclic_exception_group_cause(): +def test_exceptiongroup_starlette_collapse(): """ - Regression test for https://github.com/getsentry/sentry-python/issues/5025 + Simulates the Starlette collapse_excgroups() pattern where a single-exception + ExceptionGroup is caught and the inner exception is unwrapped and re-raised. - When using FastAPI with multiple BaseHTTPMiddleware instances, anyio wraps - exceptions in ExceptionGroups. + See: https://github.com/Kludex/starlette/blob/0e88e92b592bfa11fd92e331869a8d49ba34b541/starlette/_utils.py#L79-L87 - When a single exception is unpacked from the ExceptionGroup by Starlette and reraised in the `collapse_excgroups()` method - (see https://github.com/Kludex/starlette/blob/0e88e92b592bfa11fd92e331869a8d49ba34b541/starlette/_utils.py#L79-L87) - it causes the following situation to happen: + 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. - ExceptionGroup -> .exceptions[0] -> ValueError -> __cause__ -> ExceptionGroup + 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: - when the Sentry SDK attempts to walk through the chain of exceptions via the exceptions_from_error(). + ExceptionGroup -> .exceptions[0] -> ValueError -> __context__ -> ExceptionGroup - This is because `exceptions_from_error` walks both __cause__/__context__ and - ExceptionGroup.exceptions recursively without cycle detection, causing - infinite recursion and a silent RecursionError that drops the event. + Without cycle detection in exceptions_from_error(), this causes infinite + recursion and a silent RecursionError that drops the event. """ - # 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 + 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 - # 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, + exception_group, client_options={ "include_local_variables": True, "include_source_context": True, @@ -347,30 +369,68 @@ def test_cyclic_exception_group_cause(): mechanism={"type": "test_suite", "handled": False}, ) - exception_values = event["exception"]["values"] + 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 + # 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_context(): +def test_cyclic_exception_group_cause(): """ - Same as test_cyclic_exception_group_cause (see above) but the cycle goes through - __context__ instead of __cause__ . - - This is the more likely scenario to occur in production because __context__ is set implicitly by - Python while __cause__ is set explicitly when "raise X from Y" is written in the code + 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.__context__ = group - # __suppress_context__ = False so that exceptions_from_error follows __context__ - original.__suppress_context__ = False + 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={ @@ -382,6 +442,8 @@ def test_cyclic_exception_group_context(): ) 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 @@ -391,7 +453,9 @@ def test_cyclic_exception_group_context(): @minimum_python_311 def test_deeply_nested_cyclic_exception_group(): """ - A more complex cycle: ExceptionGroup -> ValueError -> __cause__ -> + 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") From 4cc2672f1a6e40385edb32a5e86cf940400eed35 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 26 Mar 2026 13:51:05 +0100 Subject: [PATCH 3/5] ref: Improve typing and initialization in cycle detection Type seen_exceptions as Optional[list[BaseException]] instead of Optional[list] and split the None checks for seen_exceptions and seen_exception_ids so each parameter is independently initialized. Co-Authored-By: Claude Opus 4.6 (1M context) --- sentry_sdk/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index a100fbfe68..0c12888a00 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -819,7 +819,7 @@ 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_exceptions: "Optional[list[BaseException]]" = None, seen_exception_ids: "Optional[Set[int]]" = None, ) -> "Tuple[int, List[Dict[str, Any]]]": """ @@ -831,9 +831,11 @@ def exceptions_from_error( """ if seen_exception_ids is None: - seen_exceptions = [] seen_exception_ids = set() + if seen_exceptions is None: + seen_exceptions = [] + if exc_value is not None and id(exc_value) in seen_exception_ids: return (exception_id, []) From 90df082a12fcad41ca9c185e9ee59a943e12a362 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 26 Mar 2026 14:03:44 +0100 Subject: [PATCH 4/5] cleanup --- tests/test_exceptiongroup.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/tests/test_exceptiongroup.py b/tests/test_exceptiongroup.py index 48bc2e4c83..c0d057abf8 100644 --- a/tests/test_exceptiongroup.py +++ b/tests/test_exceptiongroup.py @@ -343,16 +343,6 @@ def test_exceptiongroup_starlette_collapse(): ) 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 @@ -422,8 +412,7 @@ 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 From d516f1a8f40ab0c24c1f62e0a70bb19369398710 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 30 Mar 2026 13:45:04 +0200 Subject: [PATCH 5/5] docs: Clarify exception_id and parent_id parameters in exceptions_from_error Add docstring details distinguishing mechanism.exception_id (sequential counter) from Python id() values used for cycle detection, and explaining parent_id's role in the event payload. --- sentry_sdk/utils.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 0c12888a00..d71aa91687 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -828,6 +828,22 @@ def exceptions_from_error( See the Exception Interface documentation for more details: https://develop.sentry.dev/sdk/event-payloads/exception/ + + Args: + exception_id (int): + + Sequential counter for assigning ``mechanism.exception_id`` + to each processed exception. Is NOT the result of calling `id()` on the exception itself. + + parent_id (int): + + The ``mechanism.exception_id`` of the parent exception. + + Written into ``mechanism.parent_id`` in the event payload so Sentry can + reconstruct the exception tree. + + Not to be confused with ``seen_exception_ids``, which tracks Python ``id()`` + values for cycle detection. """ if seen_exception_ids is None: