Skip to content

Commit addf622

Browse files
leonardo-panserihynekleiyi2000hinthornw
authored
Fix unbounded recursion when traceback contains an exception that has a reference to itself in its cause chain (hynek#739)
* Fix unbounded traceback recursion * Update CHANGELOG.md * Fixes & attributions Co-authored-by: yuwu <47444399+leiyi2000@users.noreply.github.com> Co-authored-by: William FH <13333726+hinthornw@users.noreply.github.com> --------- Co-authored-by: Hynek Schlawack <hs@ox.cx> Co-authored-by: yuwu <47444399+leiyi2000@users.noreply.github.com> Co-authored-by: William FH <13333726+hinthornw@users.noreply.github.com>
1 parent 01ae286 commit addf622

File tree

3 files changed

+134
-0
lines changed

3 files changed

+134
-0
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
3737
- `structlog.processors.MaybeTimeStamper` now respects the *key* argument when determining whether to overwrite the timestamp field.
3838
[#747](https://github.com/hynek/structlog/pull/747)
3939

40+
- `structlog.tracebacks.extract()` no longer raises a *RecursionError* when the cause chain of an exception contains itself.
41+
[#739](https://github.com/hynek/structlog/pull/739)
42+
4043

4144
## [25.4.0](https://github.com/hynek/structlog/compare/25.3.0...25.4.0) - 2025-06-02
4245

src/structlog/tracebacks.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ def extract(
190190
locals_hide_dunder: bool = True,
191191
locals_hide_sunder: bool = False,
192192
use_rich: bool = True,
193+
_seen: set[int] | None = None,
193194
) -> Trace:
194195
"""
195196
Extract traceback information.
@@ -235,12 +236,23 @@ def extract(
235236
236237
.. versionchanged:: 25.4.0
237238
Handle exception groups.
239+
240+
.. versionchanged:: 25.5.0
241+
Handle loops in exception cause chain.
238242
"""
239243

240244
stacks: list[Stack] = []
241245
is_cause = False
242246

247+
if _seen is None:
248+
_seen = set()
249+
243250
while True:
251+
exc_id = id(exc_value)
252+
if exc_id in _seen:
253+
break
254+
_seen.add(exc_id)
255+
244256
stack = Stack(
245257
exc_type=safe_str(exc_type.__name__),
246258
exc_value=safe_str(exc_value),
@@ -265,6 +277,7 @@ def extract(
265277
locals_hide_dunder=locals_hide_dunder,
266278
locals_hide_sunder=locals_hide_sunder,
267279
use_rich=use_rich,
280+
_seen=_seen,
268281
)
269282
)
270283

tests/test_tracebacks.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,3 +1071,121 @@ def test_no_exception(
10711071
logger.exception("onoes")
10721072

10731073
assert [{"event": "onoes", "log_level": "error"}] == cap_logs.entries
1074+
1075+
1076+
@pytest.mark.skipif(
1077+
sys.version_info < (3, 11), reason="Requires Python 3.11 or higher"
1078+
)
1079+
def test_reraise_error_from_exception_group():
1080+
"""
1081+
There is no RecursionError when building the traceback for an exception
1082+
that has been re-raised from an ExceptionGroup.
1083+
"""
1084+
inner_lineno = None
1085+
lineno = None
1086+
1087+
try:
1088+
try:
1089+
inner_lineno = get_next_lineno()
1090+
raise ExceptionGroup( # noqa: F821
1091+
"Some error occurred",
1092+
[ValueError("value error")],
1093+
)
1094+
except ExceptionGroup as e: # noqa: F821
1095+
lineno = get_next_lineno()
1096+
raise e.exceptions[0] # noqa: B904
1097+
except Exception as e:
1098+
trace = tracebacks.extract(type(e), e, e.__traceback__)
1099+
1100+
assert lineno is not None
1101+
assert inner_lineno is not None
1102+
assert 2 == len(trace.stacks)
1103+
assert lineno == trace.stacks[0].frames[0].lineno
1104+
assert (
1105+
tracebacks.Stack(
1106+
exc_type="ValueError",
1107+
exc_value="value error",
1108+
syntax_error=None,
1109+
is_cause=False,
1110+
frames=[
1111+
tracebacks.Frame(
1112+
filename=__file__,
1113+
lineno=lineno,
1114+
name="test_reraise_error_from_exception_group",
1115+
locals=None,
1116+
)
1117+
],
1118+
is_group=False,
1119+
exceptions=[],
1120+
)
1121+
== trace.stacks[0]
1122+
)
1123+
assert (
1124+
tracebacks.Stack(
1125+
exc_type="ExceptionGroup",
1126+
exc_value="Some error occurred (1 sub-exception)",
1127+
syntax_error=None,
1128+
is_cause=False,
1129+
frames=[
1130+
tracebacks.Frame(
1131+
filename=__file__,
1132+
lineno=inner_lineno,
1133+
name="test_reraise_error_from_exception_group",
1134+
locals=None,
1135+
),
1136+
],
1137+
is_group=True,
1138+
exceptions=[tracebacks.Trace(stacks=[])],
1139+
)
1140+
== trace.stacks[1]
1141+
)
1142+
1143+
1144+
def test_exception_cycle():
1145+
"""
1146+
There is no RecursionError when building the traceback for an exception
1147+
that has itself in its cause chain.
1148+
"""
1149+
inner_lineno = None
1150+
lineno = None
1151+
1152+
try:
1153+
try:
1154+
exc = ValueError("onoes")
1155+
inner_lineno = get_next_lineno()
1156+
raise exc
1157+
except Exception as exc:
1158+
lineno = get_next_lineno()
1159+
raise exc from exc
1160+
except Exception as e:
1161+
trace = tracebacks.extract(type(e), e, e.__traceback__)
1162+
1163+
assert lineno is not None
1164+
assert inner_lineno is not None
1165+
assert 1 == len(trace.stacks)
1166+
assert lineno == trace.stacks[0].frames[0].lineno
1167+
assert (
1168+
tracebacks.Stack(
1169+
exc_type="ValueError",
1170+
exc_value="onoes",
1171+
syntax_error=None,
1172+
is_cause=False,
1173+
frames=[
1174+
tracebacks.Frame(
1175+
filename=__file__,
1176+
lineno=lineno,
1177+
name="test_exception_cycle",
1178+
locals=None,
1179+
),
1180+
tracebacks.Frame(
1181+
filename=__file__,
1182+
lineno=inner_lineno,
1183+
name="test_exception_cycle",
1184+
locals=None,
1185+
),
1186+
],
1187+
is_group=False,
1188+
exceptions=[],
1189+
)
1190+
== trace.stacks[0]
1191+
)

0 commit comments

Comments
 (0)