agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
116 lines (115 loc) • 6.25 kB
TypeScript
/**
* withRetry — provider decorator that retries failed calls.
*
* Pattern: Decorator (GoF) — wraps an `LLMProvider` and adds retry
* policy without changing its interface.
* Role: Outer ring (Hexagonal). Composable: `withRetry(withFallback(...))`.
*
* Retries `complete()` on transient failures with exponential backoff.
* `stream()` is retried under the SAME policy, but only until its first
* chunk arrives: a stream that fails before it has produced anything
* (HTTP 429, a 5xx, a reset at connect) is exactly as safe to re-open as a
* `complete()` is to re-send. Once the first chunk is in hand the stream is
* committed — the rest is plain delegation with no retry anywhere around it
* (`openStream` owns the only retry loop and returns WITH that chunk), so a
* mid-stream failure surfaces exactly as it did before: re-sending would
* replay tokens the caller has already shown.
*
* Default policy:
* • maxAttempts: 3 (initial + 2 retries)
* • backoff: exponential — 200ms, 400ms, 800ms
* • stated wait: when the failure says how long to wait (`retryAfterMs`,
* or `retryAfterSeconds` — see statedWait.ts), the wait is
* max(the schedule's delay, the stated wait), capped at
* `maxDelayMs` so a hostile header cannot stall a run. The
* adapter that knows its wire reads the header or the
* gateway's wording; this decorator never parses prose.
* • shouldRetry: rejects 4xx-class errors (client mistakes don't
* benefit from retry), AbortError, and any error that
* declares `retryable: false`; retries 5xx, network
* errors, and unknown shapes.
*
* **Status: contract-shaped and tested — independently reproduced against a
* local harness, 2026-08-13.** Somebody who is not this library's author ran
* the behaviour end to end and it held: this decorator absorbed two HTTP
* 503-shaped failures and recovered on call three, with both
* `agentfootprint.error.retried` and `agentfootprint.error.recovered` on the
* typed stream, and it made exactly ONE streaming attempt — the limit as it
* was then designed; a stream is now retried before its first chunk (see
* above), still never after. What was exercised was a SCRIPTED failing provider — the trial's
* own note is that those tests *"were local and deterministic, so they consumed
* no GCP credit"*. **No live provider outage has been retried from this
* repository**, so this is not the field-validated rung: a real 503 from a real
* vendor, with a real Retry-After and real latency, remains unexercised.
*/
import type { LLMProvider } from '../provider-contract/types.js';
export interface WithRetryOptions {
/** Total attempts including the first. Default 3. Must be >= 1. */
readonly maxAttempts?: number;
/** Initial delay in ms before the first retry. Default 200. */
readonly initialDelayMs?: number;
/** Multiplier between attempts. Default 2 (200ms → 400ms → 800ms). */
readonly backoffFactor?: number;
/**
* Maximum delay cap in ms. Default 10_000. Caps a STATED wait too: a failure
* that says "retry after 60 s" waits `maxDelayMs` at most. Raise it to honour
* longer stated waits.
*/
readonly maxDelayMs?: number;
/**
* Predicate to decide whether an error is worth retrying. Default
* skips AbortError, HTTP 4xx (except 429) and an error that declares
* `retryable: false`; retries everything else. Override
* to add provider-specific signals. (A stated wait — Retry-After — needs no
* predicate: it sets how long to wait, see the policy notes above.)
*/
readonly shouldRetry?: (error: unknown, attempt: number) => boolean;
/**
* Hook invoked before each retry. Useful for logging in standalone
* (non-agentfootprint) use. Receives the attempt number that's about
* to start (so attempt 2 = first retry).
*
* You do NOT need this to get retry telemetry inside a run: since v7.8
* the in-run LLM call sites hand this decorator an `LLMCallHooks` and
* translate its reports into `agentfootprint.error.retried` /
* `agentfootprint.error.recovered` events, stamped with the real
* `runId`/`runtimeStageId`. This hook is the consumer-owned escape
* hatch and its contract is unchanged.
*/
readonly onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
}
/**
* Wrap a provider so its `complete()` — and its `stream()`, up to the first
* chunk — retries transient failures.
*
* @example
* import { withRetry } from 'agentfootprint/resilience';
* import { anthropic } from 'agentfootprint/providers';
*
* const robust = withRetry(anthropic({ apiKey }), {
* maxAttempts: 5,
* onRetry: (err, attempt, ms) => console.warn(`retry ${attempt} in ${ms}ms`, err),
* });
*/
export declare function withRetry(provider: LLMProvider, options?: WithRetryOptions): LLMProvider;
/**
* Skip retry for AbortError, 4xx-class errors, and an error that declares
* `retryable: false`. Retry on everything else (network errors, 5xx,
* unknown shapes). Provider adapters that surface HTTP status should set
* `error.status` for this to work; the predicate falls back to retrying
* when status is unknown (better to retry once than to surface a flaky
* failure).
*
* `retryable: false` is how an adapter says a failure with no HTTP status
* cannot recover by asking again — a refusal raised before any request
* (no key, no model), or a 2xx answer it could not read, where a re-send
* may run and bill the model a second time. Only the literal `false` is
* read: an error that does not declare the field is judged exactly as
* before, and `retryable: true` does not override a 4xx status.
*
* Exported (module-level, NOT on the resilience barrel) as the single
* source of truth for the decorators' transience policy — shared by
* `withCredentialRetry` (identity) so "what counts as transient" never
* drifts between the LLM and credential retry wrappers.
*/
export declare function defaultShouldRetry(err: unknown, _attempt: number): boolean;