Skip to content

Commit 89f5277

Browse files
graph: phase 2 polish — review followups (#15)
Late-breaking feedback from the merged Phase 2 PR. All cosmetic / defensive; no behavior change to landed conformance fixtures. - retry.py: rename `state` to `_state` in `default_classifier` and drop the `del state` workaround. Underscore-prefix is the canonical Python convention for intentionally unused parameters; both ruff and pyright recognize it without the awkward `del`. - compiled.py: replace the `attempt_counter = [0]` mutable-list closure trick in `_step_function_node` with `nonlocal`. Same semantics, more idiomatic post-Python-3.0 style. - middleware/_core.py: add a "Performance note" paragraph to compose_chain explaining the per-dispatch build pattern and flagging the fan-out optimization opportunity (cache the chain at compile time, inject only the per-dispatch attempt counter via a thin wrapper). Premature without numbers; worth measuring when Phase 3's fan-out runtime lands. - test_middleware.py: two new unit tests pinning load-bearing contracts that retry/timing exercise only implicitly: - test_middleware_can_call_next_repeatedly — pins §2's reentrant- next contract by calling next 5 times in a loop. - test_timing_callback_failure_replaces_original_exception — pins current behavior when a TimingMiddleware on_complete callback raises on the failure path: the callback's exception propagates, the original is preserved on __context__ via Python's standard exception chaining. Documents the contract so a future change toward "preserve original via explicit raise-from" doesn't silently regress. Total tests: 248 passing (+2), 1 skipped.
1 parent 0103848 commit 89f5277

4 files changed

Lines changed: 83 additions & 7 deletions

File tree

src/openarmature/graph/compiled.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ async def _step_function_node(
330330
# The innermost layer dispatches the per-attempt event pair around a
331331
# single call to ``node.run``. Each call to this inner increments
332332
# the attempt counter; middleware composes around it.
333-
attempt_counter = [0]
333+
attempt_counter = 0
334334

335335
async def innermost(s: Any) -> Mapping[str, Any]:
336336
# Per pipeline-utilities §5 + graph-engine §6: per-attempt
@@ -340,8 +340,9 @@ async def innermost(s: Any) -> Mapping[str, Any]:
340340
# the original `category` attribute (timing's
341341
# exception_category, retry's classifier). The engine wraps
342342
# any exception that escapes the chain, OUTSIDE this layer.
343-
attempt_index = attempt_counter[0]
344-
attempt_counter[0] = attempt_index + 1
343+
nonlocal attempt_counter
344+
attempt_index = attempt_counter
345+
attempt_counter += 1
345346

346347
self._dispatch_started(context, current, namespace, step, s, attempt_index=attempt_index)
347348

src/openarmature/graph/middleware/_core.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,17 @@ def compose_chain(
104104
Calling it once = one full chain traversal = at LEAST one call to
105105
``innermost`` (more if a middleware calls ``next`` repeatedly, e.g.
106106
retry).
107+
108+
Performance note: this is called fresh per dispatch from
109+
``CompiledGraph._step_function_node``, producing one closure layer
110+
per middleware on every node step. For typical workloads
111+
(single-digit middleware × hundreds of node activations) this is
112+
negligible. Under heavy fan-out (Phase 3+), e.g. 10K instances × 5
113+
inner nodes × 3 middlewares = 150K closure constructions per
114+
invocation; worth measuring with realistic workloads when the
115+
fan-out runtime lands. The optimization shape (cache the chain at
116+
compile time, inject only the per-dispatch attempt counter via a
117+
thin wrapper) is straightforward but premature without numbers.
107118
"""
108119
chain: ChainCall = innermost
109120
for mw in reversed(middlewares):

src/openarmature/graph/middleware/retry.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
)
3737

3838

39-
def default_classifier(exc: Exception, state: Any) -> bool:
39+
def default_classifier(exc: Exception, _state: Any) -> bool:
4040
"""Spec §6.1 default classifier — purely category-based, ignores state.
4141
4242
Returns True if either the exception itself or its ``__cause__``
@@ -45,10 +45,12 @@ def default_classifier(exc: Exception, state: Any) -> bool:
4545
``NodeException`` wrapping an llm-provider transient — per the spec:
4646
"a `node_exception` whose `__cause__` is a transient category MUST
4747
be classified as transient."
48+
49+
The ``_state`` parameter is ignored by the default; the leading
50+
underscore is the canonical Python convention for "intentionally
51+
unused" while keeping the signature stable for user-supplied
52+
state-aware classifiers.
4853
"""
49-
# Suppress the unused-arg warning while keeping the signature stable
50-
# for user-supplied state-aware classifiers.
51-
del state
5254
direct = getattr(exc, "category", None)
5355
if isinstance(direct, str) and direct in TRANSIENT_CATEGORIES:
5456
return True

tests/unit/test_middleware.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,68 @@ async def innermost(_state: Any) -> Mapping[str, Any]:
294294
assert abs(rec.duration_ms - 10.0) < 0.01
295295

296296

297+
# ===== Reentrant next: middleware can call next more than twice =====
298+
299+
300+
async def test_middleware_can_call_next_repeatedly() -> None:
301+
"""Per spec §2: a middleware MAY call ``next`` more than once. Retry
302+
exercises this with N=2-3 attempts; this test pins the contract
303+
independently by calling ``next`` 5 times in a loop and asserting the
304+
inner runs exactly that many times."""
305+
inner_calls = [0]
306+
307+
async def call_five_times(state: Any, next_: NextCall) -> Mapping[str, Any]:
308+
last: Mapping[str, Any] = {}
309+
for _ in range(5):
310+
last = await next_(state)
311+
return last
312+
313+
async def innermost(_state: Any) -> Mapping[str, Any]:
314+
inner_calls[0] += 1
315+
return {"trace": [f"call-{inner_calls[0]}"]}
316+
317+
chain = compose_chain([call_five_times], innermost)
318+
result = await chain(TraceState())
319+
320+
assert inner_calls[0] == 5
321+
# Final result is the partial returned from the LAST call to next.
322+
assert result == {"trace": ["call-5"]}
323+
324+
325+
# ===== Timing callback failure on the failure path masks the original =====
326+
327+
328+
async def test_timing_callback_failure_replaces_original_exception() -> None:
329+
"""Pins the current behavior: when a node raises and TimingMiddleware's
330+
``on_complete`` ALSO raises in the failure path, the callback's
331+
exception propagates instead of the original. Python's standard
332+
exception chaining preserves the original on ``__context__``, but the
333+
active exception observers see is the callback's.
334+
335+
Spec §6.2 says callbacks SHOULD be fast and infallible — this test
336+
documents what happens if a user violates that, so a future change
337+
that wants to preserve the original (e.g., via explicit ``raise exc
338+
from cb_exc``) doesn't silently regress this contract.
339+
"""
340+
341+
async def bad_callback(_record: TimingRecord) -> None:
342+
raise ValueError("callback bug")
343+
344+
async def innermost(_state: Any) -> Mapping[str, Any]:
345+
raise _CategorizedFatal()
346+
347+
timing = TimingMiddleware(node_name="alpha", on_complete=bad_callback)
348+
chain = compose_chain([timing], innermost)
349+
350+
with pytest.raises(ValueError, match="callback bug") as excinfo:
351+
await chain(TraceState())
352+
353+
# The original node exception is preserved on __context__, the
354+
# standard Python exception-chaining link for "exception raised
355+
# while handling another."
356+
assert isinstance(excinfo.value.__context__, _CategorizedFatal)
357+
358+
297359
# ===== 8. Subgraph isolation =====
298360

299361

0 commit comments

Comments
 (0)