Skip to content

Commit e455f97

Browse files
Guard _resolve_cause against cyclic cause chains
Traverse only BaseException instances and track visited exceptions by id, so a non-exception __cause__ ends the walk and a self-referential chain terminates instead of hanging or raising in the degrade path.
1 parent 3dd5af2 commit e455f97

2 files changed

Lines changed: 24 additions & 1 deletion

File tree

src/openarmature/graph/middleware/failure_isolation.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,12 @@ def _resolve_cause(exc: Exception) -> BaseException:
7777

7878
origin: BaseException | None = None
7979
current: BaseException | None = exc
80-
while current is not None:
80+
seen: set[int] = set()
81+
# Traverse only BaseException instances (a non-exception ``__cause__``
82+
# ends the walk) and guard against a cyclic ``__cause__`` chain so a
83+
# malformed chain can't hang or crash the degrade path.
84+
while isinstance(current, BaseException) and id(current) not in seen:
85+
seen.add(id(current))
8186
if not isinstance(current, NodeException):
8287
if origin is None:
8388
origin = current

tests/unit/test_failure_isolation_middleware.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,24 @@ async def test_categorized_surface_wins_over_deeper_cause() -> None:
330330
assert events[0].caught_exception.message == "misconfigured"
331331

332332

333+
async def test_cyclic_cause_chain_terminates() -> None:
334+
# Defensive: a self-referential __cause__ chain must not hang the
335+
# degrade path. Resolution terminates and the node still degrades.
336+
events: list[Any] = []
337+
disp_token = _set_active_dispatch(lambda e: events.append(e))
338+
a = NodeException(node_name="a", cause=ValueError("seed"), recoverable_state={})
339+
b = NodeException(node_name="b", cause=a, recoverable_state={})
340+
a.__cause__ = b # cycle: a -> b -> a
341+
try:
342+
mw = FailureIsolationMiddleware(degraded_update={"result": []}, event_name="iso")
343+
out = await mw("s", _raises(a))
344+
finally:
345+
_reset_active_dispatch(disp_token)
346+
347+
assert out == {"result": []}
348+
assert len(events) == 1
349+
350+
333351
async def test_no_event_outside_invocation() -> None:
334352
# current_dispatch() is None outside an invocation; the degrade still
335353
# happens, no event is emitted, and nothing raises.

0 commit comments

Comments
 (0)