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.
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
6066Run with:
6167
8490 append ,
8591)
8692from openarmature .graph .middleware import (
93+ FailureIsolationMiddleware ,
94+ Middleware ,
8795 RetryConfig ,
8896 RetryMiddleware ,
8997 TimingMiddleware ,
@@ -160,16 +168,17 @@ class HeadlineState(State):
160168
161169
162170async 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