0050: Retry & Degradation Primitives — Failure-Isolation Middleware + Call-Level Retry¶
- Status: Accepted
- Author: Chris Colinsky
- Created: 2026-06-01
- Accepted: 2026-06-01
- Targets: spec/pipeline-utilities/spec.md (new §6.3 Failure isolation middleware — third bundled middleware primitive in the §6 set, alongside §6.1 retry middleware and §6.2 timing middleware; covers the catch-and-recover pattern from §2's third MAY bullet with a named shape, lifecycle, and composition pattern); spec/llm-provider/spec.md (§5
complete()extended with an optional retry kwarg accepting the same retry configuration record pipeline-utilities §6.1 defines; new §7 Call-level retry subsection defining the per-call retry contract — reuses the §6.1 retry configuration record and the §6.1 default transient classifier's category set, with a two-level lane-separation framing distinguishing per-call from per-node retry); spec/observability/spec.md (§5.5 — addsopenarmature.llm.attempt_indexper-attempt span attribute parallelingopenarmature.node.attempt_index); plus new conformance fixtures covering the failure-isolation middleware contract, call-level retry contract, attempt-index span emission, and the body+retry+isolation three-piece composition pattern. - Related: 0004 (pipeline-utilities middleware — established the §6 middleware framework + the §2 catch-and-recover MAY bullet this proposal packages), 0006 (llm-provider core — established
LlmProvider.complete()surface this proposal extends), 0024 (LLM span payload + GenAI semconv — established the §5.5 attribute surface this proposal extends withopenarmature.llm.attempt_index) - Supersedes:
Summary¶
Two retry-and-degradation primitives, bundled because their normative text shares a load-bearing lane-separation framing:
-
FailureIsolationMiddlewareadded to the pipeline-utilities §6 bundled-middleware set as §6.3, paralleling §6.1RetryMiddlewareand §6.2TimingMiddleware. Catches exceptions escaping the inner chain, returns a configured degraded partial update, and surfaces the catch via an observer event. Composes withRetryMiddlewarevia the three-piece pattern (transient-aware node body + inner retry + outer isolation) for "retry transients, degrade gracefully on exhaustion" workflows. -
Call-level retry on
LlmProvider.complete()via a new optional retry kwarg accepting the same retry configuration record pipeline-utilities §6.1 defines (max_attempts/classifier/backoff/on_retry). Handles transient LLM failures at the call boundary so a node issuing N LLM calls in a loop doesn't re-run already-successful calls when call N hits a transient failure. Reuses the §6.1 default transient classifier (categories explicitly enumerated as transient by their carrying spec). Adds anopenarmature.llm.attempt_indexper-attempt span attribute for observer disambiguation.
The two primitives ship together because the spec text needs
two-level retry lane-separation guidance as the connective
tissue — retry now lives at both the node level (pipeline-utilities
§6.1) and the call level (llm-provider §7), and the spec must
explicitly demarcate the lanes so callers don't stack overlapping
retry budgets they didn't reason about. The lane-separation table
+ a "common mistakes to avoid" list (multiplicative-budget pitfall:
3-attempt outer × 3-attempt call-level on a 5-call chunked node =
up to 45 calls) lives in llm-provider §7 alongside the per-call
retry framing, with RetryMiddleware's §6.1 text cross-referencing
it.
Both primitives are additive at the spec level. Existing pipelines see no behavioral change; pipelines opting into either primitive get the named contract + the lane-separation discipline.
Motivation¶
Three concrete pressures converge:
The catch-and-recover middleware pattern is recurring. Pipeline-
utilities §2's third MAY bullet under the middleware contract
("middleware MAY catch exceptions raised by next(state) and either
re-raise, transform, or recover, returning a partial update instead
of raising") describes the pattern; without a named primitive in the
§6 set, each implementation re-derives it independently. The two
existing bundled middlewares (§6.1 retry, §6.2 timing) set the
precedent for shipping small, focused primitives in the §6 set. The
catch-and-recover pattern deserves the same treatment — it's the
canonical companion to the §6.1 retry middleware for "retry then
give up gracefully" flows.
Per-call retry granularity matters for chunked-loop nodes. Node-
level RetryMiddleware (§6.1) is the right scope when a node's
entire body is the retry unit. But nodes that issue N LLM calls in
a loop (chunked processing, sequential multi-step, retrieve-then-
reason patterns) face a real cost when RetryMiddleware re-runs the
whole node body on a transient failure of call N: calls 1..N-1 are
re-executed unnecessarily. That's wasted spend, wasted latency,
and (for non-idempotent side effects in hybrid nodes) potentially
incorrect. Call-level retry on LlmProvider.complete() gives
surgical retry granularity exactly where it's needed.
Lane separation prevents budget compounding. With retry living
at two levels (per-call in llm-provider §7, per-node in
pipeline-utilities §6.1), the multiplicative-budget concern is
real: a 3-attempt outer RetryMiddleware wrapping a 5-call chunked
node with 3-attempt per-call retry can issue up to 45 LLM calls in
the worst case. The spec must demarcate the lanes explicitly so
callers don't stack overlapping retries without realizing the
combinatorial cost. The lane-separation table + "common mistakes"
list is the load-bearing connective tissue that makes the two-level
retry surface sane.
The bundle is justified by the lane-separation framing — splitting the two primitives into separate proposals would force the lane-separation text to live in only one (leaving the other incomplete) or duplicate it (drift risk). The two primitives' code surfaces are independent at the implementation layer; they can land as separate implementation changes. The spec text wants them together because of the shared connective text.
Proposed change¶
pipeline-utilities §6.3 — Failure isolation middleware (new)¶
Add a new §6.3 subsection between §6.2 (timing middleware) and any subsequent §6 content, following the §6.1 / §6.2 template.
6.3 Failure isolation middleware
FailureIsolationMiddlewarecatches exceptions escaping the inner chain and returns a configured degraded partial update. The named primitive packages the §2 third-MAY-bullet pattern ("middleware MAY catch ... and either re-raise, transform, or recover") with a stable contract and observer-event surface, avoiding per-downstream re-derivation.Configuration:
degraded_update(required) — the partial state update returned on caught exceptions. MAY be a static mapping OR a callablestate -> partial_updatefor cases where the degraded shape depends on input state. Resolved at catch time.event_name(required, no default) — a stable identifier for this catch site. Surfaces on the observer event AND any default logging. Required with no default because useful values are node-specific (e.g.,"segment_extraction_failure_isolated") and a generic"failure_isolated"default would make downstream telemetry strictly worse — a single dashboard tile labeledfailure_isolatedhides which specific path degraded. Forcing the name at the construction site puts the decision where the right context is available.predicate(optional) — a callableException -> bool. When supplied, only exceptions wherepredicate(exc) is Trueare caught; others propagate. Defaults to "always True" (catch allException). Mirrors the §6.1 retry-middleware classifier shape in returning bool, with a single-argument signature (Exception -> bool) rather than retry's two-argument(exception, state) -> boolform. State-dependent predicates aren't a documented use case for failure isolation today; the simpler signature is sufficient for v1.on_caught(optional) — an async callableException -> Awaitable[void]. Optional hook fired when the middleware catches an exception. Lets consumers pump caught exceptions to caller-specific telemetry (custom logger, metric counter, etc.) beyond the default observer event.Catch semantics:
- Catches
Exceptionby default;BaseException(cancellation, keyboard interrupt) propagates. Same rule asRetryMiddlewareper §6.1.- On a caught exception:
- Emit an observer event (see Observability below).
- Call
on_caught(exc)if supplied.- Resolve
degraded_update(static OR callable) and return the partial update as the node's normal return value.- The graph engine continues edge resolution from the
FailureIsolationMiddleware-wrapped node's degraded return per the normal §3 / §4 contract. The engine does NOT see the exception; from its perspective, the node returned normally.Observability. When the middleware catches an exception, it dispatches a framework-emitted failure-isolation event onto the same observer delivery queue as
NodeEventper graph-engine §6. The event is a distinct kind fromNodeEvent— it does NOT reuseNodeEvent.node_name/namespace(which graph-engine §6 reserves for registered-node identity) and does NOT reuseNodeEvent.error(a graph-engine §4 category). It is a separate framework-emitted observer event variant carrying its own field set:
event_name— the caller-supplied event name from the middleware's configuration (a stable identifier for this catch site).- The wrapped node's identity per graph-engine §6 (
namespace,attempt_index,fan_out_index,branch_name— the same lineage tupleNodeEventcarries, surfaced here for correlation with the wrapped node's other events).pre_state/post_statereflecting the wrapped node's input and degraded return.caught_exception— a structured record carrying the caught exception's category (per its carrying spec, e.g., llm-provider §7 / graph-engine §4) and the exception message.This pattern parallels graph-engine §6's RECOMMENDED metadata- augmentation event mechanism from proposal 0040 — a distinct framework-emitted event kind on the observer delivery queue, with the typed shape per-language idiom. Spec mandates the event mechanism + field set; per-language implementations choose the concrete typed shape (e.g., a Python dataclass, a TypeScript interface) following the language's naming idioms.
A future proposal MAY promote the failure-isolation event to a spec-mandated typed variant on the observer event union (paralleling proposal 0049's
LlmCompletionEventcarve-out) if the pattern accumulates across multiple middleware primitives. For v1, the event-mechanism framing (per 0040's pattern) is sufficient.Default emission is observer-event-only. The middleware MUST NOT pin a logging library (stdlib
logging, structlog, etc.) — the default emission path is the observer event. Consumers wanting additional logging attach their own observer or useon_caught.Composition with
RetryMiddleware— the three-piece pattern. Pipelines that want "retry transients, give up gracefully on exhaustion or non-transient errors" compose three pieces:
- Node body keeps a transient-aware
exceptblock — re-raise on exceptions whose category §6.1's default classifier treats as transient (provider_unavailable,provider_rate_limit, etc.), degrade in-place on non-transient categories.- Inner middleware:
RetryMiddleware(§6.1) — sees raw transients and retries them per its policy; on exhaustion, propagates the exception.- Outer middleware:
FailureIsolationMiddleware— catches the exhaustion-propagated exception (or any non-transient that re-raised through the node body'sexcept) and returns the configured degraded update.Outer-to-inner ordering is load-bearing: retry MUST be inner (it sees raw transients first); failure isolation MUST be outer (it only sees what escapes retry). Reversing the order would let the inner isolation catch transients before retry sees them, defeating retry's purpose entirely. Implementations SHOULD document this composition explicitly in the middleware API documentation.
llm-provider §5 — complete() retry parameter¶
Extend the §5 complete() interface with an optional retry
parameter:
The signature extends with an additional optional keyword parameter,
retry, accepting an instance of pipeline-utilities §6.1's retry middleware configuration record (or null/absent). Default is null/absent — no retry; the v0.4.0 behavior is preserved verbatim.When
retryis absent/null,complete()behaves exactly as today — transient errors per §7 raise to the caller without retry. Whenretryis supplied, the provider implementation performs an in-call retry loop per the new §7 Call-level retry subsection (loops on transient errors matching the §6.1 default classifier's category set, applies the configured backoff between attempts, propagates the final error whenmax_attemptsis exhausted).
llm-provider §7 — Call-level retry (new subsection)¶
Add a new §7 subsection after the existing retry-classification text:
Call-level retry. When
complete()is called with a non- nullretryparameter (per §5), the provider implementation performs an in-call retry loop:
- On each attempt, dispatch the underlying request as it would for a non-retried call.
- If the response is successful, return immediately.
- If the response raises an exception classified as transient by the retry record's
classifierfield (default behavior matches the §6.1 default transient classifier), wait perbackoff(attempt_index)and re-attempt.- If
max_attemptsis exhausted, propagate the final error per the normalcomplete()exception path.- Exceptions classified as non-transient propagate immediately on first occurrence (no retry).
Configuration record reuse. The retry parameter accepts the same configuration record pipeline-utilities §6.1 defines — the four-field shape (
max_attempts/classifier/backoff/on_retry) is framework-agnostic and reusable across the per- node and per-call retry contexts. Implementations MUST accept the same configuration record instance a caller would pass to the §6.1 retry middleware. (Cross-spec reference direction: llm- provider §7 here references pipeline-utilities §6.1, which is the inverse of pipeline-utilities §6.1's existing dependency on llm-provider §7 for transient category names. The two-way dependency is acceptable because the shared retry config record is framework-agnostic and the per-section content remains independently coherent.)Transient classification. The default
classifierfield's behavior matches the §6.1 Default transient classifier text — the same categories §6.1 enumerates as transient (provider_ unavailable,provider_rate_limit,provider_model_not_loaded, plus categories marked transient by their carrying spec) trigger the per-call retry loop. Callers MAY supply a user-definedclassifierif their application has additional retriable categories or context-dependent retry policies.Backoff behavior. The
backofffield's(attempt_index) -> delay_secondscontract from §6.1 applies unchanged at the call-level retry. The §6.1 default (exponential with full jitter, base 1s, cap 30s) applies when the caller doesn't override; implementations MAY ship additional named backoff strategies per §6.1's MAY clause.Per-attempt span emission. Each retry attempt produces its own
openarmature.llm.completespan per observability §5.5 — N retry attempts emit N LLM spans, all parented under the calling node's span. The per-attempt span carries the newopenarmature.llm.attempt_indexattribute (per observability §5.5 below). The final-error category lands on the LAST attempt's span; earlier failed-then-retried attempts carry their own per-span error categories.Two-level retry lane separation. Retry primitives operate at two semantic levels in OA:
Layer Spec section Semantic unit Use when Per-call retry llm-provider §7 (this section) A single complete()callA node issues multiple LLM calls in a loop; you want to avoid re-running successful calls when a later call's transient fails Per-node retry pipeline-utilities §6.1 RetryMiddlewareA whole node invocation A node does LLM + non-LLM work (DB writes, parses, side effects); you want to re-run the entire body on failure The layers compose: per-call exhausts → propagates → per-node retry catches → re-runs whole node → per-call budgets reset for each fresh per-node attempt.
Common mistakes to avoid:
- Multiplicative budget on chunked nodes. Stacking the §6.1 retry middleware (configured with
max_attempts=3) over a node that issues 5 LLM calls, each with a per-call retry record configured formax_attempts=3, can issue up to 3 × 5 × 3 = 45 LLM calls in the worst case. The budget multiplies. Authors stacking both layers should pick intentional budgets per layer.- Inline retry via try/except inside the node body. Implementing retry as a try/except inside the node loses the per-attempt span attribution and the backoff-utility integration. Use the
retrykwarg instead.- Widening the transient classifier to mask real errors. The §6.1 default classifier excludes non-transient categories for a reason. Supplying a custom
classifierthat retries onprovider_invalid_requestorstructured_output_invalid(for example) masks bugs rather than working around transient infrastructure issues. Custom classifiers SHOULD widen the default only for categories that are genuinely transient but not yet enumerated by §6.1.
observability §5.5 — openarmature.llm.attempt_index attribute + per-attempt span amendment¶
§5.5 today frames LLM provider span emission as "a span around each
complete() call" (one span per call). This proposal amends that
framing to "one span per attempt under call-level retry; one
span per complete() call when retry is absent (the default —
§5.5's existing one-span-per-call framing preserved verbatim)".
The amendment is required because call-level retry per
llm-provider §7 produces N attempts inside a single complete()
call; emitting one span per attempt is the observability shape
backends expect for retry attribution. The new
openarmature.llm.attempt_index attribute discriminates the N
spans.
Add a new attribute to the §5.5 LLM provider span attribute set:
openarmature.llm.attempt_index— int. The retry-attempt index for the LLM call, where0is the first attempt and0..N-1covers the N spans produced by an N-attempt call-level retry per llm-provider §7. Emitted on every LLM provider span; defaults to0when call-level retry is not configured on thecomplete()call (a single attempt). Paralleled withopenarmature.node.attempt_indexper §5.2 for node-level retry; the two attributes are independent (a per-call retry attempt 0 may be nested under a node-level attempt 1, etc.).The attribute lives in the
openarmature.llm.*namespace per the §5.5.2 framing precedent for OA-specific attributes; if the OpenTelemetry GenAI semconv adds a stablegen_ai.*equivalent in a future release, a follow-on proposal MAY mirror this attribute to both namespaces per the existing §5.5.3 mirror pattern (openarmature.llm.model+gen_ai.request.model).
Conformance test impact¶
New fixtures¶
Ten new fixtures (numbers assigned at acceptance):
Failure-isolation middleware (pipeline-utilities/conformance/):
-
Static degraded update. A node wrapped with
FailureIsolationMiddleware(degraded_update={"result": []}, event_name="extraction_failed")that raises an exception. Asserts the wrapped node returns{"result": []}to the engine; the engine continues edge resolution from the degraded return; the observer receives a framework-emitted failure-isolation event withevent_name = "extraction_failed", the wrapped node's lineage identity (namespace,attempt_index, etc.) matching the wrapped node, and acaught_exceptionrecord carrying the raised exception's category and message. -
Callable degraded update. Same shape as fixture 1 but
degraded_update = lambda state: {"result": []}for input- state-dependent degraded shapes. Asserts the callable receives the pre-call state and its return becomes the partial update. -
Predicate filtering. A node wrapped with
FailureIsolationMiddleware(predicate=lambda exc: isinstance(exc, ValueError), ...). Raises aKeyError(not caught — propagates) and separately raises aValueError(caught — degraded returned). Asserts predicate filtering works per the §6.3 semantics. -
Three-piece composition pattern. A node wrapped with both
RetryMiddleware(max_attempts=2)(inner) andFailureIsolationMiddleware(degraded_update={"result": []}, event_name="x_failed")(outer). The node body raises a transient error twice (retry exhausts) then propagates;FailureIsolationMiddlewarecatches the propagated error. Asserts the three-piece pattern works end-to-end: retry'sopenarmature.node.attempt_indexattribute shows attempts 0 and 1; the middleware emits its observer event; the degraded return reaches the engine.
Call-level retry (llm-provider/conformance/):
-
Per-call retry on transient. A graph with one LLM-calling node, a mocked provider returning a
provider_unavailableerror on attempt 0 and a successful response on attempt 1, andcomplete()called with a retry parameter configured formax_attempts=2. Asserts the call returns the successful response; two LLM spans emit withopenarmature.llm.attempt_index = 0and1respectively; the first attempt's span carries theprovider_unavailableerror category. -
Per-call retry exhaustion. Same shape as fixture 5 but the mocked provider returns
provider_unavailableon both attempts. Asserts the call raises the finalprovider_unavailableerror (propagates per the §7 exception path); two LLM spans emit with attempt indices 0 and 1, both carrying the error category. -
Non-transient propagates without retry. A graph with one LLM-calling node, a mocked provider returning HTTP 400 (→
provider_invalid_requestper §7), andcomplete()called with a retry parameter configured formax_attempts=3. Asserts the call raisesprovider_invalid_requeston attempt 0 (the retry loop does NOT iterate on non-transient categories); exactly one LLM span emits withattempt_index = 0carrying the error category. Locks down §7.1's non-transient-immediate-propagation rule. -
on_caughtcallback fires. A node wrapped withFailureIsolationMiddlewarewhoseon_caughtis a recording callback. The node raises a transient exception; the middleware catches. Asserts the callback fires exactly once with the original exception, the framework-emitted observer event still emits (the callback is additive, not replacing), and the degraded return reaches the engine. Locks down §6.3'son_caughtoptional-hook contract. -
Default predicate catches bare exception. A node wrapped with
FailureIsolationMiddleware(nopredicatesupplied — default always-true) raises a bareValueErrorcarrying no category. Asserts the middleware catches; the framework-emitted event'scaught_exception.categoryisnullandmessagecapturesstr(exc). Locks down §6.3's default-predicate-catches-all + null-category-on-non-categorized rules.
Observability single-attempt default (observability/conformance/):
openarmature.llm.attempt_indexsingle-attempt default. A graph with one LLM-calling node, noretrykwarg oncomplete(). Asserts exactly one LLM provider span emits carryingopenarmature.llm.attempt_index = 0alongside the baseline §5.5 attributes (model,finish_reason,usage.*). Locks down the §5.5 single-span backwards-compat contract (the existing single-span framing preserved verbatim when retry is absent) AND the new attribute's default-value contract (attempt_index = 0when call-level retry is not configured).
Unaffected fixtures¶
All existing fixtures continue to pass unchanged. The middleware primitive and call-level retry kwarg are additive — existing pipelines that don't use them see no behavioral change.
Versioning¶
MINOR bump (pre-1.0). On acceptance the whole-spec SemVer increments:
- New
FailureIsolationMiddlewareas pipeline-utilities §6.3 (additive — existing §6.1 / §6.2 unchanged). - New
retrykwarg onLlmProvider.complete()per §5 (additive — defaultNonepreserves current behavior verbatim). - New §7 Call-level retry subsection in llm-provider (additive — current §7 retry-classification text unchanged).
- New
openarmature.llm.attempt_indexattribute on the §5.5 LLM provider span surface (additive — existing attributes unchanged; emitted with default value0for non-retried calls per the attribute definition). - New conformance fixtures (ten required). Existing fixtures unchanged.
The change is backwards-compatible across all three capabilities.
Alternatives considered¶
-
Split into two proposals (failure-isolation only + call-level retry only). Land them as independent proposals without the bundle. Rejected: the two-level retry lane- separation framing is load-bearing for both primitives and would need to live in one of them (leaving the other incomplete) or be duplicated (drift risk between the two proposals' lane tables). Bundling consolidates the connective text in one canonical home. The proposals' code surfaces are independent at the impl level — they can ship as separate PRs downstream — but the spec text wants them together.
-
Make
event_nameoptional onFailureIsolationMiddlewarewith a default of"failure_isolated". Easier to use; one less required argument. Rejected: a generic"failure_isolated"default makes downstream logs and dashboards strictly worse — a single dashboard tile labeledfailure_isolatedhides which specific path degraded across N different middleware-wrapped nodes. Requiring the name forces the naming decision once, at the middleware construction site, where the right context is available. -
Typed
MiddlewareEventvariant on the observer event union (instead of the framework-emitted event mechanism). Promote the failure-isolation event to a spec-mandated typed variant on the observer event union, paralleling proposal 0049'sLlmCompletionEventcarve-out. Rejected for v1: middleware events are low-frequency (only fire when a middleware catches; not on every node execution) and have a narrower consumer audience than LLM completions. The Observer event union shouldn't grow a typed variant for a one-off middleware emission. The framework-emitted event mechanism (paralleling proposal 0040's metadata-augmentation event pattern) is the right ceremony level for v1 — spec mandates the event mechanism + field set; the typed shape is per-language idiom. If/when other middleware events accumulate (rate-limit, circuit-breaker, etc.), a follow-on proposal can promote them as a family. -
Sibling
complete_with_retry()method instead ofretrykwarg. Have llm-provider expose a separate method for retried calls. Rejected: kwarg is cleaner —retry=Nonedefault preserves the existing call surface verbatim, composes with other call params (response_schema,config,tool_choice), and avoids surface-area duplication (complete()+complete_with_retry()). -
Spec-defined LLM-specific retry config record instead of reusing pipeline-utilities §6.1's record. Define a sibling retry configuration record in llm-provider rather than cross- referencing pipeline-utilities §6.1's shape. Rejected: the fields are framework-agnostic (
max_attempts, backoff function, classifier, on_retry); duplicating them across two specs is busywork without value. Cross-spec reference is the cleaner shape. The cross-spec dependency direction is acceptable — llm-provider §7 referencing pipeline-utilities §6.1 here inverts the existing pipeline-utilities §6.1 → llm-provider §7 reference (for transient category names), but the two-way dependency stays clean because the shared configuration record is itself framework-agnostic. -
Use
gen_ai.attempt_indexinstead ofopenarmature.llm.attempt_index. Mirror the GenAI semconv attribute namespace directly. Rejected per the docs/compatibility.md Stable-only adoption policy: the OTel GenAI semconv doesn't currently have a stablegen_ai.attempt_indexattribute (and at the time of writing, no Development-status equivalent either). Per the policy, OA usesopenarmature.llm.attempt_indexas the OA-namespace shape; a follow-on proposal mirrors togen_ai.*when an upstream stable name emerges. The verification check belongs at Accept time per the docs/compatibility.md verification discipline. -
Cap call-level retry budgets at the framework level (multiplicative-budget prevention). Have the framework enforce a maximum total LLM-call budget per node, preventing the worst-case 3 × 5 × 3 = 45-call scenario. Rejected for v1: the worst case requires deliberate stacking (the caller configures both layers); a framework cap would surprise callers who intentionally wanted the budget. The "common mistakes to avoid" list in §7 documents the pitfall; spec doesn't impose a cap. A follow-on MAY add an opt-in budget ceiling if the pitfall surfaces frequently in practice.
Open questions¶
None at draft time. The design choices are settled in the proposal text above:
- Bundle vs split (alternative 1) — bundled; lane-separation framing is the connective tissue.
event_namerequired vs default (alternative 2) — required with no default; the naming decision is per-catch-site.- Typed middleware event vs framework-emitted event mechanism (alternative 3) — framework-emitted event mechanism for v1 (parallels proposal 0040's metadata-augmentation event pattern); spec mandates the event mechanism + field set, typed shape is per-language idiom; future proposal MAY promote to a typed variant family if accumulation warrants.
retrykwarg vs siblingcomplete_with_retry()(alternative 4) — kwarg per the precedent ofresponse_schema/config/tool_choiceparameters.- Shared retry configuration record vs sibling shape (alternative 5) — reuse the pipeline-utilities §6.1 record; framework-agnostic fields, cross-spec reference is cleaner. Two-way cross-spec dependency direction (pipeline-utilities §6.1 → llm-provider §7 for category names; llm-provider §7 → pipeline-utilities §6.1 for the retry record) is acceptable because the shared config record is framework-agnostic.
openarmature.llm.attempt_indexvsgen_ai.attempt_index(alternative 6) — OA namespace for v1 per the stable-only adoption policy; verify at Accept that no upstream stablegen_ai.*attribute has emerged.- Multiplicative-budget cap (alternative 7) — out of scope for v1; documented in the "common mistakes" list.
If reviewers surface a substantive question during PR review, it gets resolved into the proposal text rather than left here as a defer.
Out of scope¶
- Sub-node retry for non-LLM call sites. Call-level retry on DB writes, HTTP calls to non-LLM services, etc. — out of scope. Those call sites have their own retry abstractions (DB connection pools, HTTP client libraries) and adding a framework-level retry surface for them would duplicate that infrastructure. This proposal scopes to LLM provider calls only.
- Framework-level multiplicative-budget cap (alternative 7). Documented as a pitfall in §7's "common mistakes" list; spec does not impose a cap.
- Typed middleware event variant family (alternative 3). Framework-emitted event mechanism for v1 (per 0040's pattern); future promotion to spec-mandated typed variant via follow-on if accumulation warrants.
- Streaming-retry semantics. Per-call retry on a streaming
complete()call (where the LLM returns chunks rather than a single response) raises questions the spec doesn't address (partial-content handling, resumption from mid-stream, etc.). Out of scope for v1; streaming completion patterns are a separate concern. - Retry budget aggregation across nodes. A pipeline-level budget that aggregates retry attempts across all nodes in an invocation — out of scope; per-call and per-node budgets are independent.
- Rate-limit middleware. A separate retry-adjacent primitive (token-bucket / leaky-bucket rate limiting) is a logical follow-on to §6.3 but warrants its own design discussion; bundling here would expand scope. Out of scope for this proposal.
- Circuit-breaker middleware. Same framing as rate-limit — a natural follow-on primitive, scoped out of v1 to keep the bundle focused on the retry + degradation pair specifically.