agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
110 lines • 6.05 kB
JavaScript
;
/**
* withCredentialRetry — CredentialProvider decorator that retries transient
* `getCredential` failures with exponential backoff.
*
* Pattern: Decorator (GoF) — the credential twin of `withRetry` (resilience)
* for LLM providers. SAME option vocabulary (`maxAttempts` /
* `initialDelayMs` / `backoffFactor` / `maxDelayMs` / `shouldRetry` /
* `onRetry`) and the SAME default transience policy (the shared
* `defaultShouldRetry`: skip AbortError + 4xx except 429; retry 5xx,
* network errors, unknown shapes). AgentCore's documented transient
* errors (`InternalServerException` 500, `ThrottlingException` 429)
* retry out of the box.
*
* Why a decorator and not a reliability rule: the rules-based reliability
* subsystem (`Agent.create(...).reliability({...})`) is LLM-call-scoped —
* `ReliabilityScope` carries `request: LLMRequest` and the gate chart loops
* around the CallLLM stage with provider failover. Credential resolution
* happens at a different boundary (the tool-dispatch loop, declare-and-push);
* promoting it to a chart-level gate is the deferred `sf-credential` node.
* Until that exists, retry is a TRANSPORT property of the credential provider
* — exactly like `withRetry` is for LLM transports.
*
* Semantics:
* • Only THROWN errors retry. Both result branches return immediately:
* `issued` is success; `authorization-required` is a HUMAN flow (3LO
* consent), not a transient fault — retrying it would hammer the IdP
* without the user having authorized anything.
* • The request's `signal` (`CredentialRequest.signal`) is passed through to
* the wrapped provider, ends the backoff wait, and is checked immediately
* before every attempt, the first included: a request whose signal has
* fired never asks the provider (again), and the call rejects with the
* abort. An attempt already running is the provider's to stop.
* • After retries exhaust, the LAST error is rethrown — the call site
* behaves byte-identically to an unwrapped provider (fail-closed:
* `agentfootprint.credential.failed` emit + error tool result; the tool
* does NOT run).
* • Visibility: the agent trace brackets the whole retried resolution with
* `credential.requested` → `credential.acquired` / `credential.failed`.
* Per-attempt visibility is consumer-wired via `onRetry` — the established
* decorator contract (same as `withRetry`; the `agentfootprint.error.*`
* event family is reserved for these decorators, see events/payloads.ts).
* No new event types.
*
* @example
* import { agentCoreIdentity, withCredentialRetry } from 'agentfootprint/security';
*
* const credentials = withCredentialRetry(agentCoreIdentity({ region: 'us-east-1' }), {
* maxAttempts: 3,
* onRetry: (err, attempt, ms) => console.warn(`credential retry ${attempt} in ${ms}ms`, err),
* });
* const agent = Agent.create({ provider, model, credentials }).tools([...]).build();
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.withCredentialRetry = void 0;
const abort_js_1 = require("../lib/abort.js");
const sleep_js_1 = require("../lib/sleep.js");
const withRetry_js_1 = require("../resilience/withRetry.js");
/**
* Wrap a {@link CredentialProvider} so transient `getCredential` failures
* retry with exponential backoff before failing closed.
*
* Defaults mirror `withRetry`: 3 attempts total, 200ms → 400ms → 800ms
* backoff capped at 10s, retry on 5xx/429/network/unknown, never on
* AbortError or other 4xx.
*/
function withCredentialRetry(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 ?? withRetry_js_1.defaultShouldRetry;
const onRetry = options.onRetry;
return {
id: `${provider.id}+retry`,
async getCredential(req) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Immediately before EVERY attempt, the first included: a request whose
// signal has fired never asks the provider — not before the first
// attempt, and not after a wait or an `onRetry` that cancelled it (a
// zero wait never consults the signal).
(0, abort_js_1.throwIfAborted)(req.signal);
try {
// Both result branches return as-is (same object — the wrapper never
// clones or serializes the credential): `issued` is success;
// `authorization-required` is consent, not a fault — never retried.
return await provider.getCredential(req);
}
catch (err) {
lastError = err;
// A failure that lands after the abort is the abort, whatever
// `shouldRetry` says: nobody is waiting for the credential (`lib/abort.ts`).
(0, abort_js_1.throwIfAborted)(req.signal);
if (attempt >= maxAttempts || !shouldRetry(err, attempt)) {
throw err;
}
const delay = Math.min(maxDelayMs, initialDelayMs * Math.pow(backoffFactor, attempt - 1));
onRetry?.(err, attempt + 1, delay);
// The request's signal (`CredentialRequest.signal`) ends the wait too.
await (0, sleep_js_1.sleep)(delay, req.signal, abort_js_1.abortErrorOf);
}
}
// Unreachable — the last attempt either returned or threw above.
throw lastError;
},
};
}
exports.withCredentialRetry = withCredentialRetry;
//# sourceMappingURL=withCredentialRetry.js.map