agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
332 lines • 15.6 kB
JavaScript
;
/**
* withCircuitBreaker — provider decorator that fails fast after N
* consecutive failures.
*
* Pattern: Circuit Breaker (Nygard, *Release It!*) — wraps an
* `LLMProvider` and tracks consecutive failures. After
* `failureThreshold` failures, the breaker OPENS and
* rejects all calls without invoking the wrapped provider.
* After `cooldownMs`, the breaker enters HALF-OPEN and
* allows probe calls; success closes the breaker, failure
* re-opens it.
*
* Role: Outer ring (Hexagonal). Composes with `withRetry` and
* `withFallback`:
*
* ```
* withFallback(
* withCircuitBreaker(anthropic(...)), // ← stop hammering on outage
* withCircuitBreaker(openai(...)),
* )
* ```
*
* When Anthropic 503s for the 5th time, the breaker opens
* and `complete()` throws `CircuitOpenError` immediately —
* no network round-trip — which `withFallback` then
* catches and routes to OpenAI. After 30 seconds the
* breaker probes Anthropic with a single call; if it
* succeeds, normal operation resumes.
*
* Why a circuit breaker on top of `withRetry`?
* - `withRetry` keeps hammering one provider with exponential
* backoff — it doesn't know the vendor is down.
* - During a multi-minute Anthropic outage, every request still
* burns 3 retries + backoff = ~3 sec of latency before failing
* to the fallback. Multiplied by your QPS, that's a lot of
* wasted time + tokens (some retries DO get billed).
* - The breaker says: "we just saw 5 failures in a row; stop
* calling for 30 seconds." Subsequent requests fail in <1ms,
* `withFallback` routes immediately to OpenAI.
*
* Three states:
*
* CLOSED ──[ N consecutive failures ]──► OPEN
* ▲ │
* │ │ [cooldownMs elapsed]
* │ ▼
* └──[ M probe successes ]──── HALF-OPEN
*
* HALF-OPEN ──[ probe failure ]──► OPEN (cooldown restarts)
*
* `stream()` is decorated identically. `name` passes through unchanged
* (the consumer's existing observability hooks still see the underlying
* provider's identity), and the optional per-call `LLMCallHooks` is
* forwarded inward so a decorator nested inside this one can still
* report. Nothing else is copied — `LLMProvider` has exactly `name`,
* `complete` and the optional `stream`.
*
* **Scope: per-instance, NOT distributed.** Each `withCircuitBreaker(...)`
* call holds its own breaker state in process memory. If you run 100
* server replicas, each has its own independent breaker — one
* instance can be CLOSED while another is OPEN. This is intentional
* (no shared state means no Redis dependency, no SPOF, no
* partial-cluster-blast-radius surprises) and matches Hystrix's
* default behavior. For cluster-wide coordination, layer your own
* Redis-backed counter on top via the `onStateChange` hook +
* `shouldCount` predicate.
*
* **Scope: PROVIDER-level, not per-tool — by design (B16).** The breaker
* wraps `LLMProvider` because a provider outage has unbounded blast
* radius: the LLM call is every run's heartbeat, fired once per
* iteration at your full QPS, and a failure aborts the call path. Tool
* failures don't share that shape — the agent's tool dispatch catches a
* tool throw and feeds the error message back to the model as the tool
* result (see `core/agent/stages/toolCalls.ts`), so the ReAct loop
* itself absorbs and adapts (retry, alternate tool, give up), serialized
* and bounded by the iteration budget. A flaky tool can't hammer
* anything the way provider QPS can. For the rare tool that needs a
* breaker today: its `execute` is a plain async function — wrap it with
* any breaker yourself, or hide it dynamically via a `gatedTools`
* predicate (the visible-tool list is recomputed every iteration).
* First-class per-tool breakers (state keyed by tool name; run-scoped
* vs process-scoped TBD) are a possible future enhancement.
*
* **Status: contract-shaped and tested — independently reproduced against a
* local harness, 2026-08-13.** Somebody who is not this library's author drove
* this breaker through its whole walk: it opened after two failures, served the
* next request from a fallback WITHOUT calling the primary, half-opened after
* cooldown, and closed after two successful probes. The one gap that run named
* — *"breaker transitions have no typed AgentFootprint event"* — is what 9.32.0
* closes with the `'circuit-changed'` report below, and the per-process scope
* stated further down was restated there and is unchanged.
*
* The failures were SCRIPTED and the cooldown was a test clock: those tests
* *"were local and deterministic, so they consumed no GCP credit"*. **No live
* provider has tripped this breaker from this repository**, so this is not the
* field-validated rung — a real degradation, with a real vendor's error mix and
* a real recovery time, remains unexercised.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.withCircuitBreaker = exports.CircuitOpenError = void 0;
const requestRefusal_js_1 = require("../provider-contract/requestRefusal.js");
// ─── Public error type ───────────────────────────────────────────────
/**
* Thrown by the wrapped provider when the breaker is OPEN. Carries
* the underlying root-cause error from the most recent failure so
* consumers can observe what tripped the breaker.
*/
class CircuitOpenError extends Error {
code = 'ERR_CIRCUIT_OPEN';
/** The error that tripped the breaker (or the most recent failure
* during HALF-OPEN that re-opened it). */
cause;
/** Wall-clock timestamp at which the breaker may next probe. */
retryAfter;
constructor(providerName, cause, retryAfter) {
super(`[${providerName}] circuit breaker is OPEN — failing fast (next probe at ${new Date(retryAfter).toISOString()}). Underlying error: ${cause?.message ?? String(cause)}`);
this.name = 'CircuitOpenError';
this.cause = cause;
this.retryAfter = retryAfter;
}
}
exports.CircuitOpenError = CircuitOpenError;
/**
* Wrap a provider with a circuit breaker.
*
* @example
* ```ts
* import { anthropic, openai } from 'agentfootprint/providers';
* import { withCircuitBreaker, withFallback } from 'agentfootprint/resilience';
*
* const provider = withFallback(
* withCircuitBreaker(anthropic({ apiKey }), { failureThreshold: 5, cooldownMs: 30_000 }),
* withCircuitBreaker(openai({ apiKey })),
* );
* ```
*/
function withCircuitBreaker(inner, options = {}) {
const failureThreshold = options.failureThreshold ?? 5;
const cooldownMs = options.cooldownMs ?? 30_000;
const halfOpenSuccessThreshold = options.halfOpenSuccessThreshold ?? 2;
const shouldCount = options.shouldCount ?? defaultShouldCount;
const onStateChange = options.onStateChange;
const breaker = {
state: 'closed',
consecutiveFailures: 0,
consecutiveSuccesses: 0,
openedAt: 0,
lastError: undefined,
};
/**
* Move the breaker, and say so on both channels.
*
* A no-op re-entry returns before either fires, which is what makes the
* report stream a record of TRANSITIONS rather than of calls: a hundred
* requests rejected while the breaker is already open produce zero events.
*
* `hooks` is the per-CALL channel, so it is threaded down from whichever
* `complete`/`stream` invocation caused the move. Every transition happens
* inside one — including `'cooldown elapsed'`, which is discovered by the
* next admitted call rather than by a timer — so the event always lands
* with the run's real correlation ids and nothing is ever synthesized.
*/
function transition(next, reason, hooks) {
if (breaker.state === next)
return;
breaker.state = next;
if (next === 'open') {
breaker.openedAt = Date.now();
breaker.consecutiveSuccesses = 0;
}
else if (next === 'half-open') {
breaker.consecutiveSuccesses = 0;
}
else if (next === 'closed') {
breaker.consecutiveFailures = 0;
breaker.consecutiveSuccesses = 0;
breaker.lastError = undefined;
}
hooks?.onResilience?.({
kind: 'circuit-changed',
state: next,
reason,
providerName: inner.name,
});
onStateChange?.(next, reason);
}
/** Decide whether to admit a call. Mutates state if cooldown
* elapsed (open → half-open). Returns true to admit, false to
* reject with CircuitOpenError. */
function admit(hooks) {
if (breaker.state === 'closed' || breaker.state === 'half-open')
return true;
// OPEN — check cooldown.
if (Date.now() - breaker.openedAt >= cooldownMs) {
transition('half-open', 'cooldown elapsed', hooks);
return true;
}
return false;
}
function recordSuccess(hooks) {
if (breaker.state === 'half-open') {
breaker.consecutiveSuccesses += 1;
if (breaker.consecutiveSuccesses >= halfOpenSuccessThreshold) {
transition('closed', `${halfOpenSuccessThreshold} probe successes`, hooks);
}
}
else if (breaker.state === 'closed') {
// Successful call resets the failure counter.
breaker.consecutiveFailures = 0;
}
}
function recordFailure(err, hooks) {
if (!shouldCount(err))
return;
breaker.lastError = err;
if (breaker.state === 'half-open') {
// Probe failed — re-open the breaker.
transition('open', 'half-open probe failed', hooks);
return;
}
if (breaker.state === 'closed') {
breaker.consecutiveFailures += 1;
if (breaker.consecutiveFailures >= failureThreshold) {
transition('open', `${breaker.consecutiveFailures} consecutive failures`, hooks);
}
}
}
function rejectFastIfOpen(hooks) {
if (!admit(hooks)) {
throw new CircuitOpenError(inner.name, breaker.lastError, breaker.openedAt + cooldownMs);
}
}
const wrapped = {
name: inner.name,
// Same wire, same roles — a breaker only decides WHETHER to call. See the
// note in withRetry: this rebuilds the provider object, so the capability
// has to be carried across explicitly or it degrades to the floor.
...(inner.carriesInMessages !== undefined && {
carriesInMessages: inner.carriesInMessages,
}),
// Likewise for the forced tool choice — a breaker decides WHETHER to
// call, never what the wire can express.
...(inner.carriesForcedToolChoice !== undefined && {
carriesForcedToolChoice: inner.carriesForcedToolChoice,
}),
...(inner.supportsForcedToolChoice !== undefined && {
supportsForcedToolChoice: inner.supportsForcedToolChoice,
}),
// And for the structured-output format (`strategy: 'native'`).
...(inner.carriesNativeOutputSchema !== undefined && {
carriesNativeOutputSchema: inner.carriesNativeOutputSchema,
}),
...(inner.supportsNativeOutputSchema !== undefined && {
supportsNativeOutputSchema: inner.supportsNativeOutputSchema,
}),
...(inner.supportsAutoNativeOutputSchema !== undefined && {
supportsAutoNativeOutputSchema: inner.supportsAutoNativeOutputSchema,
}),
// And for prompt caching: a breaker never changes what reaches the wire.
...(inner.promptCaching !== undefined && { promptCaching: inner.promptCaching }),
...(inner.thinkingHandler !== undefined && { thinkingHandler: inner.thinkingHandler }),
...(inner.thinkingMode !== undefined && { thinkingMode: inner.thinkingMode }),
async complete(req, hooks) {
rejectFastIfOpen(hooks);
try {
// Forward `hooks` inward as well as using them here: this decorator
// rebuilds a fresh provider object, so an inner decorator's reports
// are silently swallowed unless they're passed through.
const res = await inner.complete(req, hooks);
recordSuccess(hooks);
return res;
}
catch (err) {
// A call the caller cancelled says nothing about the vendor's health,
// whatever the error's name — never counted (lib/abort.ts).
if (!req.signal?.aborted)
recordFailure(err, hooks);
throw err;
}
},
// `stream` is optional on `LLMProvider`. Only define our wrapper
// if the underlying provider supports streaming — otherwise leave
// it undefined so the consumer's existing capability check
// (`if (provider.stream)`) still works correctly.
...(inner.stream && {
async *stream(req, hooks) {
rejectFastIfOpen(hooks);
let yieldedAnyChunk = false;
try {
// This stream() method is conditionally defined only when
// inner.stream exists (see `...(inner.stream && {` above), so
// inner.stream is guaranteed non-null inside this body.
// `hooks` is forwarded for the same reason as in complete().
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
for await (const chunk of inner.stream(req, hooks)) {
yieldedAnyChunk = true;
yield chunk;
}
recordSuccess(hooks);
}
catch (err) {
// Only count as a breaker-tripping failure if the stream
// failed BEFORE yielding any tokens. Mid-stream errors are
// less indicative of vendor health (could be a content-filter
// trip on this specific request).
if (!yieldedAnyChunk && !req.signal?.aborted)
recordFailure(err, hooks);
throw err;
}
},
}),
};
return wrapped;
}
exports.withCircuitBreaker = withCircuitBreaker;
// ─── Default predicates ──────────────────────────────────────────────
function defaultShouldCount(error) {
// Don't count user cancellations.
const e = error;
if (e?.name === 'AbortError')
return false;
if (e?.code === 'ABORT_ERR')
return false;
// Nor a request the adapter refused before sending it: that says the
// REQUEST is wrong, not the vendor — counting it would open the breaker
// on healthy calls.
if ((0, requestRefusal_js_1.isRequestRefusal)(error))
return false;
return true;
}
//# sourceMappingURL=withCircuitBreaker.js.map