Skip to content

Commit f275136

Browse files
Add 0050 example coverage: degrade mode and call-level retry (#152)
* Add degrade mode to fan-out-with-retry example Demonstrate FailureIsolationMiddleware as a third per-instance failure posture beside fail_fast and collect: wrapped outermost in the instance middleware (retry stays inner), an instance whose retries exhaust is caught and degraded to a placeholder so the batch finishes intact. Rename the demo env knob COLLECT_MODE to MODE (fail_fast/collect/degrade) and update the example and middleware concept docs. * Add call-level retry to chat-with-multimodal Pass complete(retry=RetryConfig(...)) on the respond node to retry the LLM wire call on transient categories, and grow the example's failure-handling rundown from three placements to four. Update the example and LLMs concept docs. * Validate mode in fan-out-with-retry build_graph Raise ValueError on an unknown mode instead of silently falling back to fail_fast, so a mistyped MODE fails loudly. The docs assert the three valid values.
1 parent 433632b commit f275136

6 files changed

Lines changed: 152 additions & 64 deletions

File tree

docs/concepts/llms.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,3 +643,6 @@ classifier won't do this for them.
643643
agent-loop pattern with two local tools.
644644
- [Examples: Multimodal prompt](../examples/multimodal-prompt.md)
645645
for content blocks alongside versioned prompts.
646+
- [Examples: Chat with multimodal](../examples/chat-with-multimodal.md)
647+
for call-level `complete(retry=...)` on a multi-turn chat node,
648+
alongside the other LLM-failure-handling placements.

docs/concepts/middleware.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,12 @@ failure isolation, which degrades. Reverse the order and the inner
291291
isolation would swallow transients before retry ever saw them, defeating
292292
the retry entirely.
293293

294+
The [fan-out with retry example](../examples/fan-out-with-retry.md)
295+
applies this composition as `instance_middleware` in its `degrade`
296+
mode: each fan-out instance is wrapped isolation-outer / retry-inner,
297+
so an instance whose retries exhaust degrades to a placeholder result
298+
and the batch finishes instead of aborting.
299+
294300
## Related
295301

296302
- [Parallel branches](parallel-branches.md): per-branch middleware

docs/examples/chat-with-multimodal.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ turn's `render()` injects the grown history into the placeholder.
6565
framework dispatching them; this example shows how the
6666
prompt-management layer composes a multi-turn conversation. A
6767
production chat agent often combines both.
68+
- Handling transient LLM failures. The `respond` node passes
69+
[`complete(retry=...)`](../concepts/llms.md) for call-level retry
70+
(retrying just the provider call, not the whole node), and `main()`
71+
catches `NodeException` at the `invoke()` boundary to surface the
72+
failure category. The module docstring enumerates the full set of
73+
placement options.
6874

6975
## How to run
7076

docs/examples/fan-out-with-retry.md

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@ The per-instance subgraph is small (`summarize → classify`) and
1919
would also run standalone against a single headline. Fan-out
2020
multiplies it out across the batch.
2121

22-
A second mode, controlled by the `COLLECT_MODE` env var, exercises
23-
the failure path. With `COLLECT_MODE=1` the demo prepends a
24-
sentinel headline that always raises `ProviderUnavailable`; under
25-
`error_policy="collect"` the failure lands in
26-
`state.instance_errors` and the rest of the batch completes.
22+
The `MODE` env var selects the per-instance failure posture. The
23+
default `fail_fast` aborts the batch on the first instance whose
24+
retries exhaust. `collect` and `degrade` both prepend a sentinel
25+
headline that always raises `ProviderUnavailable`, then handle it
26+
differently: `collect` lands the failure in `state.instance_errors`
27+
and finishes the rest of the batch, while `degrade` wraps each
28+
instance in `FailureIsolationMiddleware` so an exhausted instance is
29+
caught and replaced with a placeholder summary, leaving the batch
30+
intact.
2731

2832
## What it teaches
2933

@@ -42,7 +46,11 @@ sentinel headline that always raises `ProviderUnavailable`; under
4246
- `concurrency=3` capping how many instances run in flight at once.
4347
- `error_policy="fail_fast"` (default, first exhausted-retry
4448
failure aborts the batch) vs `"collect"` (failures land in
45-
`errors_field` and the batch produces partial results).
49+
`errors_field` and the batch produces partial results). The
50+
`degrade` mode keeps `fail_fast` but adds
51+
`FailureIsolationMiddleware` as the outermost instance middleware,
52+
so an exhausted instance is caught and degraded to a placeholder
53+
before the fan-out ever sees the failure.
4654
- A `fan_out_config_observer` reads
4755
`NodeEvent.fan_out_config` on the fan-out node's dispatch event,
4856
recording the resolved `item_count` / `concurrency` /
@@ -77,10 +85,15 @@ uv sync --group examples
7785
LLM_API_KEY=sk-... uv run python examples/fan-out-with-retry/main.py
7886
```
7987

80-
To exercise the collect path with a synthetic failure:
88+
To exercise a failure posture with a synthetic failure:
8189

8290
```bash
83-
COLLECT_MODE=1 LLM_API_KEY=sk-... \
91+
# record the failure and finish the batch
92+
MODE=collect LLM_API_KEY=sk-... \
93+
uv run python examples/fan-out-with-retry/main.py
94+
95+
# degrade the failed instance to a placeholder and finish the batch
96+
MODE=degrade LLM_API_KEY=sk-... \
8497
uv run python examples/fan-out-with-retry/main.py
8598
```
8699

@@ -103,7 +116,9 @@ flowchart TD
103116

104117
`headline_runs` is the fan-out node. At dispatch time it expands
105118
into N copies of the per-instance subgraph, one per headline.
106-
`RetryMiddleware` and `TimingMiddleware` wrap each instance.
119+
`RetryMiddleware` and `TimingMiddleware` wrap each instance (plus
120+
`FailureIsolationMiddleware` as the outermost layer in `degrade`
121+
mode).
107122

108123
## Reading the output
109124

@@ -112,7 +127,7 @@ A clean default-mode run (`fail_fast`, all instances succeed):
112127
```
113128
========================================================================
114129
Summarizing 5 headlines in parallel (concurrency=3)
115-
error_policy='fail_fast'
130+
mode='fail_fast'
116131
========================================================================
117132
118133
[observer] fan-out node 'headline_runs' dispatching: item_count=5 concurrency=3 error_policy='fail_fast'
@@ -154,8 +169,16 @@ Per-instance timings (in completion order):
154169
a value near 1.0 indicates concurrency didn't help (the upstream
155170
serialized you, or instances themselves are short).
156171

157-
With `COLLECT_MODE=1`, the output includes the sentinel headline
158-
at index 0 with a `(failed after retries; ...)` marker, plus a
172+
With `MODE=collect`, the output includes the sentinel headline at
173+
index 0 with a `(failed after retries; ...)` marker, plus a
159174
`Captured 1 per-instance error(s):` block listing the failed
160-
`fan_out_index` and error category. The other instances complete
161-
as usual.
175+
`fan_out_index` and error category. The other instances complete as
176+
usual.
177+
178+
With `MODE=degrade`, the sentinel at index 0 instead shows a
179+
placeholder result (`summary: (unavailable)`, `topic: other`) and
180+
there is no error block: `FailureIsolationMiddleware` caught the
181+
exhausted-retry failure and returned the degraded partial, so the
182+
fan-out recorded the instance as a (degraded) success. The
183+
per-instance timings still show the sentinel's failed attempts, so
184+
you can see the retries happened before the instance was degraded.

examples/chat-with-multimodal/main.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,13 @@
5151
(`provider_rate_limit`, `provider_invalid_request`, etc.) in the
5252
error message. The image URL failure mode (OpenAI's
5353
fetcher hitting a CDN that blocks it) lands here as
54-
`provider_invalid_request`. Three legitimate places to handle
54+
`provider_invalid_request`. Four legitimate places to handle
5555
this in production: caller-side `try / except NodeException`
56-
(shown here), `RetryMiddleware` wrapping the respond node for
57-
transient categories, or a `try / except LlmProviderError`
58-
inside the node body returning a fallback response.
56+
(shown here), call-level retry via `complete(retry=...)` for
57+
transient categories (also shown here, on the respond node),
58+
`RetryMiddleware` wrapping the whole respond node, or a
59+
`try / except LlmProviderError` inside the node body returning a
60+
fallback response.
5961
6062
**Configuration** (env vars; OpenAI defaults shown):
6163
@@ -118,6 +120,7 @@
118120
State,
119121
append,
120122
)
123+
from openarmature.graph.middleware import RetryConfig
121124
from openarmature.llm import (
122125
AssistantMessage,
123126
LlmProviderError,
@@ -330,9 +333,17 @@ async def respond(state: ChatState) -> dict[str, Any]:
330333
placeholders={"history": state.history},
331334
)
332335

336+
# Call-level retry: retry only the provider wire call on transient
337+
# categories (provider_unavailable, provider_rate_limit, ...), using
338+
# the default classifier and backoff. It is terminal-only, so the
339+
# node still sees exactly one completion (or one final failure) even
340+
# when an attempt was retried underneath. Contrast with a
341+
# RetryMiddleware on the node, which re-runs the whole node body
342+
# (re-render + re-send) on each retry.
333343
response = await _get_provider().complete(
334344
rendered.messages,
335345
config=RuntimeConfig(temperature=0.0, max_tokens=400),
346+
retry=RetryConfig(max_attempts=3),
336347
)
337348

338349
# The rendered messages include [system, *history, current_user]
@@ -496,10 +507,10 @@ async def main() -> None:
496507
# it's an ``LlmProviderError`` we surface the canonical
497508
# ``.category`` string (``provider_rate_limit``,
498509
# ``provider_invalid_request``, etc.) so the failure mode is
499-
# immediately greppable. This is one of three legitimate places
500-
# to handle the error; see the docstring for the other two
501-
# (``RetryMiddleware`` wrapping the node, ``try/except`` inside
502-
# the node body).
510+
# immediately greppable. This is one of four legitimate places
511+
# to handle the error; see the docstring for the others
512+
# (call-level ``complete(retry=...)``, ``RetryMiddleware`` wrapping
513+
# the node, ``try/except`` inside the node body).
503514
final: ChatState | None = None
504515
try:
505516
final = await graph.invoke(initial)
@@ -512,8 +523,9 @@ async def main() -> None:
512523
print()
513524
print(f"*** node {exc.node_name!r} failed ({category}): {cause} ***")
514525
print()
515-
print("Three places to handle this in production code:")
526+
print("Four places to handle this in production code:")
516527
print(" - Caller-side try/except NodeException (this example).")
528+
print(" - Call-level complete(retry=...) on the wire call (this example).")
517529
print(" - RetryMiddleware on the node for transient categories.")
518530
print(" - try/except inside the node body returning a fallback.")
519531
finally:

examples/fan-out-with-retry/main.py

Lines changed: 79 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,23 @@
2525
- ``instance_middleware=(RetryMiddleware(...), TimingMiddleware(...))``
2626
wraps EACH instance's whole subgraph invocation. Retries are
2727
per-instance: a failure on headline 3 doesn't restart headlines 0-2.
28+
In ``degrade`` mode a ``FailureIsolationMiddleware`` is prepended as
29+
the outermost layer (retry stays inner, so it still sees raw
30+
transients first).
2831
- ``concurrency=3`` caps how many instances run in flight at once. Use
2932
this to be polite to the upstream API.
30-
- ``error_policy`` defaults to ``"fail_fast"``; the first instance
31-
failure (after retries exhaust) raises and cancels siblings. Set
32-
the ``COLLECT_MODE`` env var to switch to ``"collect"``: each
33-
instance runs independently and per-instance failures land in
34-
``state.instance_errors`` instead of aborting the batch. The
35-
``errors_field="instance_errors"`` knob names where the records go.
36-
Under COLLECT_MODE, the demo prepends a sentinel headline
37-
(``[FORCE_FAIL] ...``) that ``summarize`` raises
38-
``ProviderUnavailable`` on; retry exhausts, the error lands in
39-
``instance_errors``, and the rest of the batch completes. Without
40-
the sentinel, ``COLLECT_MODE`` would have nothing to capture.
33+
- The ``MODE`` env var selects the per-instance failure posture.
34+
``"fail_fast"`` (default) raises on the first instance whose retries
35+
exhaust and cancels its siblings. ``"collect"`` lets each instance
36+
run independently and lands per-instance failures in
37+
``state.instance_errors`` (named by ``errors_field``) instead of
38+
aborting. ``"degrade"`` wraps each instance in
39+
``FailureIsolationMiddleware`` (outermost) so an exhausted instance
40+
is caught and returns a placeholder partial, leaving the batch intact
41+
with a degraded entry in place. ``collect`` and ``degrade`` both
42+
prepend a sentinel headline (``[FORCE_FAIL] ...``) that ``summarize``
43+
raises ``ProviderUnavailable`` on, so there is a failure to handle;
44+
``fail_fast`` keeps the list clean for the happy path.
4145
- A ``TimingRecord`` is captured per instance via an ``on_complete``
4246
callback. ``TimingRecord`` carries the per-call duration but not the
4347
``fan_out_index``; that index lives on observer NodeEvents instead.
@@ -56,6 +60,8 @@
5660
- ``LLM_BASE_URL`` defaults to ``https://api.openai.com``. **Host root only.**
5761
- ``LLM_MODEL`` defaults to ``gpt-4o-mini``.
5862
- ``LLM_API_KEY`` required (empty for local servers that don't authenticate).
63+
- ``MODE`` defaults to ``fail_fast``. One of ``fail_fast`` / ``collect`` /
64+
``degrade`` (see the failure-posture bullet above).
5965
6066
Run with:
6167
@@ -84,6 +90,8 @@
8490
append,
8591
)
8692
from openarmature.graph.middleware import (
93+
FailureIsolationMiddleware,
94+
Middleware,
8795
RetryConfig,
8896
RetryMiddleware,
8997
TimingMiddleware,
@@ -160,16 +168,17 @@ class HeadlineState(State):
160168

161169

162170
async def summarize(s: HeadlineState) -> Mapping[str, Any]:
163-
# Sentinel for the COLLECT_MODE demo. Raising a transient error
164-
# (ProviderUnavailable carries the ``provider_unavailable``
165-
# category, which retry's default classifier recognizes as
166-
# retryable) lets the retry middleware exhaust its 3 attempts;
167-
# the final failure then surfaces according to the fan-out's
168-
# error_policy. Under fail_fast (default), the batch aborts.
169-
# Under collect, the failure lands in instance_errors and the
170-
# batch produces partial results.
171+
# Sentinel for the collect / degrade failure-path demos (those modes
172+
# prepend a [FORCE_FAIL] headline). Raising a transient error
173+
# (ProviderUnavailable carries the ``provider_unavailable`` category,
174+
# which retry's default classifier recognizes as retryable) lets the
175+
# retry middleware exhaust its 3 attempts; the final failure then
176+
# surfaces according to MODE: under collect it lands in
177+
# instance_errors and the batch produces partial results; under
178+
# degrade FailureIsolationMiddleware catches it and substitutes a
179+
# placeholder so the batch finishes intact.
171180
if "[FORCE_FAIL]" in s.headline:
172-
raise ProviderUnavailable("synthetic failure: provider unavailable (COLLECT_MODE demo)")
181+
raise ProviderUnavailable("synthetic failure: provider unavailable (failure-path demo)")
173182
content = await _chat(
174183
system=(
175184
"Rewrite the headline as one short sentence (~15 words) that would work as a lead. No preamble."
@@ -249,16 +258,26 @@ async def present(s: BatchState) -> Mapping[str, Any]:
249258
return {"trace": ["present"]}
250259

251260

252-
def build_graph(error_policy: str = "fail_fast") -> CompiledGraph[BatchState]:
261+
def build_graph(mode: str = "fail_fast") -> CompiledGraph[BatchState]:
253262
"""Build the fan-out demo graph.
254263
255-
``error_policy`` switches between ``"fail_fast"`` (default; first
256-
exhausted-retry failure raises and cancels the rest) and
257-
``"collect"`` (each instance runs independently; failures land in
258-
``state.instance_errors`` and the batch produces partial results).
264+
``mode`` selects the per-instance failure posture:
265+
266+
- ``"fail_fast"`` (default): the first instance whose retries
267+
exhaust raises and cancels the rest.
268+
- ``"collect"``: each instance runs independently; failures land in
269+
``state.instance_errors`` and the batch produces partial results.
270+
- ``"degrade"``: each instance is additionally wrapped (outermost)
271+
in ``FailureIsolationMiddleware``; an instance whose retries
272+
exhaust is caught and returns a placeholder partial, so the batch
273+
completes with a degraded entry in place rather than aborting or
274+
dropping it.
275+
259276
The smoke test calls this with no argument, exercising the default
260-
path; main() lets the COLLECT_MODE env var flip to collect.
277+
path; main() lets the MODE env var pick the posture.
261278
"""
279+
if mode not in ("fail_fast", "collect", "degrade"):
280+
raise ValueError(f"mode must be one of fail_fast / collect / degrade; got {mode!r}")
262281
headline_subgraph = build_headline_subgraph()
263282

264283
retry = RetryMiddleware(
@@ -275,6 +294,25 @@ def build_graph(error_policy: str = "fail_fast") -> CompiledGraph[BatchState]:
275294
clock=time.monotonic,
276295
)
277296

297+
instance_middleware: tuple[Middleware, ...] = (retry, timing)
298+
error_policy = "fail_fast"
299+
if mode == "collect":
300+
error_policy = "collect"
301+
elif mode == "degrade":
302+
# Outermost instance middleware: catches the exception retry
303+
# re-raises once its attempts exhaust and returns a degraded
304+
# partial in place of the instance result, so the batch finishes
305+
# instead of aborting (fail_fast) or dropping the instance
306+
# (collect). Retry stays inner so it still sees raw transients
307+
# first. The degraded mapping is keyed the way the fan-out
308+
# projects an instance: the collect_field (``summary``) plus
309+
# each parent extra_outputs key (``topics``).
310+
degrade = FailureIsolationMiddleware(
311+
degraded_update={"summary": "(unavailable)", "topics": "other"},
312+
event_name="headline_degraded",
313+
)
314+
instance_middleware = (degrade, retry, timing)
315+
278316
return (
279317
GraphBuilder(BatchState)
280318
.add_node("announce", announce)
@@ -287,7 +325,7 @@ def build_graph(error_policy: str = "fail_fast") -> CompiledGraph[BatchState]:
287325
target_field="summaries",
288326
extra_outputs={"topics": "topic"},
289327
concurrency=3,
290-
instance_middleware=(retry, timing),
328+
instance_middleware=instance_middleware,
291329
error_policy=error_policy,
292330
errors_field="instance_errors",
293331
)
@@ -336,23 +374,23 @@ async def main() -> None:
336374
# doesn't accumulate timings across invocations.
337375
_timings.clear()
338376

339-
# Set COLLECT_MODE=1 to switch the fan-out error policy from the
340-
# default fail_fast to collect. Under collect, each instance runs
341-
# independently and per-instance failures (after retries exhaust)
342-
# land in state.instance_errors instead of aborting the batch.
343-
error_policy = "collect" if os.environ.get("COLLECT_MODE") else "fail_fast"
344-
graph = build_graph(error_policy=error_policy)
377+
# MODE selects the per-instance failure posture: fail_fast (default,
378+
# abort on the first exhausted-retry failure), collect (record
379+
# failures in state.instance_errors and finish the batch), or
380+
# degrade (FailureIsolationMiddleware catches an exhausted instance
381+
# and substitutes a placeholder so the batch finishes intact).
382+
mode = os.environ.get("MODE", "fail_fast")
383+
graph = build_graph(mode=mode)
345384
graph.attach_observer(fan_out_config_observer)
346385

347-
# Under COLLECT_MODE, prepend a deliberately-failing headline so
348-
# the collect path is exercised end-to-end: retry middleware
349-
# exhausts on the sentinel, the failure lands in
350-
# state.instance_errors, and the rest of the batch completes.
351-
# Default (fail_fast) keeps the headline list clean so the demo's
386+
# collect and degrade both need a failure to demonstrate, so prepend
387+
# a deliberately-failing headline that summarize() always raises on.
388+
# collect lands it in state.instance_errors; degrade catches it and
389+
# substitutes a placeholder. fail_fast keeps the list clean so the
352390
# happy path runs to completion.
353-
if error_policy == "collect":
391+
if mode in ("collect", "degrade"):
354392
headlines = [
355-
"[FORCE_FAIL] Synthetic failing headline for the COLLECT_MODE demo",
393+
"[FORCE_FAIL] Synthetic failing headline for the failure-path demo",
356394
*HEADLINES,
357395
]
358396
else:
@@ -361,7 +399,7 @@ async def main() -> None:
361399

362400
print("=" * 72)
363401
print(f"Summarizing {len(headlines)} headlines in parallel (concurrency=3)")
364-
print(f"error_policy={error_policy!r}")
402+
print(f"mode={mode!r}")
365403
print("=" * 72)
366404
print()
367405

0 commit comments

Comments
 (0)