agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
305 lines • 15.4 kB
JavaScript
;
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.defaultShouldRetry = exports.withRetry = void 0;
const sleep_js_1 = require("../lib/sleep.js");
const abort_js_1 = require("../lib/abort.js");
const statedWait_js_1 = require("./statedWait.js");
const replayAttempt_js_1 = require("./replayAttempt.js");
const requestObserver_js_1 = require("../provider-contract/requestObserver.js");
/**
* 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),
* });
*/
function withRetry(provider, options = {}) {
const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
const initialDelayMs = options.initialDelayMs ?? 200;
const backoffFactor = options.backoffFactor ?? 2;
const maxDelayMs = options.maxDelayMs ?? 10_000;
const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
const onRetry = options.onRetry;
const wrapped = {
name: `${provider.name}+retry`,
// A retry calls the SAME wire, so it carries exactly what that wire
// carries. Forwarded rather than inherited: this object is rebuilt from
// scratch, and a dropped capability silently narrows to the
// user/assistant floor — a wrapped OpenAI provider would start refusing
// system-role delivery it can perfectly well do.
...(provider.carriesInMessages !== undefined && {
carriesInMessages: provider.carriesInMessages,
}),
// Same wire, same forced-choice answer. Dropping this one degrades to
// "not declared", which is a REFUSAL — a wrapped Anthropic provider would
// start refusing `strategy: 'tool-forced'` it can perfectly well do.
...(provider.carriesForcedToolChoice !== undefined && {
carriesForcedToolChoice: provider.carriesForcedToolChoice,
}),
...(provider.supportsForcedToolChoice !== undefined && {
supportsForcedToolChoice: provider.supportsForcedToolChoice,
}),
// Same wire, same structured-output format (`strategy: 'native'`) — the
// same refusal-if-dropped reasoning as the forced choice above.
...(provider.carriesNativeOutputSchema !== undefined && {
carriesNativeOutputSchema: provider.carriesNativeOutputSchema,
}),
...(provider.supportsNativeOutputSchema !== undefined && {
supportsNativeOutputSchema: provider.supportsNativeOutputSchema,
}),
...(provider.supportsAutoNativeOutputSchema !== undefined && {
supportsAutoNativeOutputSchema: provider.supportsAutoNativeOutputSchema,
}),
// Same wire, same cache. The agent chooses its cache strategy from this
// declaration — it used to look the strategy up by `name`, and the
// `+retry` suffix above sent every retried Anthropic call out with no
// cache markers at all.
...(provider.promptCaching !== undefined && { promptCaching: provider.promptCaching }),
// And the thinking it returns: the agent used to look the handler up by
// `name`, so a retried Anthropic provider dropped its signed thinking.
...(provider.thinkingHandler !== undefined && { thinkingHandler: provider.thinkingHandler }),
// And which thinking each model takes — dropped, the agent could no
// longer refuse `.thinking()` for a model that cannot think.
...(provider.thinkingMode !== undefined && { thinkingMode: provider.thinkingMode }),
async complete(req, hooks) {
const attempts = (0, replayAttempt_js_1.replayAttempts)(req, hooks);
// t0 for the `recovered` report's totalDurationMs. New
// instrumentation (the decorator did not measure this before v7.8),
// not a recovered fact.
const startedMs = Date.now();
for (let attempt = 1;; attempt++) {
const invocation = (0, requestObserver_js_1.requestAttempt)(attempts.hooks, attempts.request);
try {
const res = await provider.complete(attempts.request, invocation.hooks);
reportRecovered(hooks, attempt, startedMs);
return attempts.response(res);
}
catch (err) {
invocation.close();
await backOffOrThrow(err, attempt, req, hooks);
}
finally {
invocation.close();
}
}
},
};
/**
* The ONE retry decision both doors share: throw `err` when the budget is
* spent or the policy says no; otherwise report the retry (consumer hook +
* in-run hooks channel) and sleep the backoff, honouring `req.signal`.
*/
async function backOffOrThrow(err, attempt, req, hooks) {
// A cancelled call is never asked again, whatever `shouldRetry` says — a
// retry would re-send (and re-bill) a request nobody is waiting for. The
// failure is the cancellation itself (lib/abort.ts).
(0, abort_js_1.throwIfAborted)(req.signal);
if (attempt >= maxAttempts || !shouldRetry(err, attempt))
throw err;
const scheduled = Math.min(maxDelayMs, initialDelayMs * Math.pow(backoffFactor, attempt - 1));
// The server's word beats our guess — waiting less than it asked only
// spends an attempt on a refusal already announced. Capped by the same
// policy maximum, so a hostile or garbled wait cannot stall the run.
const stated = (0, statedWait_js_1.statedRetryAfterMs)(err);
const delay = stated === undefined ? scheduled : Math.min(maxDelayMs, Math.max(scheduled, stated));
onRetry?.(err, attempt + 1, delay);
hooks?.onResilience?.({
kind: 'retried',
attempt: attempt + 1,
maxAttempts,
lastError: err instanceof Error ? err.message : String(err),
backoffMs: delay,
reason: classifyRetryReason(err),
// Absent when nothing was stated — the event is byte-identical to the
// schedule-only one.
...(stated !== undefined && { statedWaitMs: stated }),
});
await (0, sleep_js_1.sleep)(delay, req.signal);
}
if (provider.stream) {
// Guarded by `if (provider.stream)`; bound once so the closures below
// need no assertion. `hooks` is FORWARDED on every attempt: a stacked
// inner decorator (withRetry(withFallback(...))) would otherwise go dark
// on the stream path.
const innerStream = provider.stream.bind(provider);
/**
* Open the inner stream and wait for its FIRST step — the only span a
* retry can cover. Returns with that step in hand, so by the time the
* caller sees a chunk this loop has already been left for good.
*/
const openStream = async (req, hooks) => {
const attempts = (0, replayAttempt_js_1.replayAttempts)(req, hooks);
const startedMs = Date.now();
for (let attempt = 1;; attempt++) {
const invocation = (0, requestObserver_js_1.requestAttempt)(attempts.hooks, attempts.request);
try {
const iterator = attempts.iterator(innerStream(attempts.request, invocation.hooks)[Symbol.asyncIterator]());
const first = await iterator.next();
// The stream got through: its first step arrived. Reported here —
// not at the terminal chunk — because this is where retrying ends
// (a consumer that stops reading at `done: true` would otherwise
// never see it), and so `recovered` lands before the first token.
reportRecovered(hooks, attempt, startedMs);
return { iterator, first, close: invocation.close };
}
catch (err) {
invocation.close();
await backOffOrThrow(err, attempt, req, hooks);
}
}
};
wrapped.stream = async function* retryingStream(req, hooks) {
const { iterator, first, close } = await openStream(req, hooks);
if (first.done === true) {
close();
return;
}
// From here on there is no catch: nothing below can start a retry.
let delegated = false;
try {
yield first.value;
delegated = true;
// `yield*` forwards the consumer's return()/throw() to the inner
// stream, exactly as the old pass-through did.
yield* { [Symbol.asyncIterator]: () => iterator };
}
finally {
close();
// A consumer that stopped at the first chunk still closes the wire.
if (!delegated)
await iterator.return?.();
}
};
}
return wrapped;
}
exports.withRetry = withRetry;
/**
* Report a recovery — only a success that FOLLOWED a failure is one; a
* first-try success reports nothing (mirrors the rules loop's guard in
* reliabilityExecution.ts).
*/
function reportRecovered(hooks, attempt, startedMs) {
if (attempt === 1)
return;
hooks?.onResilience?.({
kind: 'recovered',
attempt,
totalDurationMs: Date.now() - startedMs,
});
}
/**
* Classify the ERROR that caused a retry, for `ResilienceReport.reason`.
*
* Reads exactly the fields `defaultShouldRetry` inspects, so the label
* always describes the same signal the default policy acted on. Note this
* is a classification of the error, NOT of the predicate's reasoning —
* `shouldRetry` returns a bare boolean, so a custom predicate's rationale
* is unknowable here. `'http-4xx'` is only reachable via a custom
* predicate (the default rejects non-429 4xx), which itself is a useful
* signal.
*/
function classifyRetryReason(err) {
const status = err?.status ?? err?.statusCode;
if (typeof status !== 'number')
return 'no-status';
if (status === 429)
return 'http-429';
if (status >= 500)
return 'http-5xx';
if (status >= 400)
return 'http-4xx';
return `http-${status}`;
}
// ── Defaults ────────────────────────────────────────────────────────
/**
* 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.
*/
function defaultShouldRetry(err, _attempt) {
if (isAbortError(err))
return false;
if (err?.retryable === false)
return false;
const status = err?.status ??
err?.statusCode;
if (typeof status === 'number' && status >= 400 && status < 500) {
// 429 Too Many Requests is the one 4xx that benefits from retry.
return status === 429;
}
return true;
}
exports.defaultShouldRetry = defaultShouldRetry;
function isAbortError(err) {
if (!err || typeof err !== 'object')
return false;
const e = err;
return e.name === 'AbortError' || e.code === 'ABORT_ERR';
}
//# sourceMappingURL=withRetry.js.map