UNPKG

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
/** * 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;