agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
162 lines • 8.21 kB
TypeScript
/**
* 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.
*/
import type { LLMProvider } from '../provider-contract/types.js';
export interface WithCircuitBreakerOptions {
/** Consecutive failures before the breaker OPENS. Default 5. */
readonly failureThreshold?: number;
/** How long the breaker stays OPEN before probing. Default 30s. */
readonly cooldownMs?: number;
/** Successes required in HALF-OPEN to fully CLOSE. Default 2. */
readonly halfOpenSuccessThreshold?: number;
/**
* Predicate — does this error count toward the threshold? Default:
* everything except an AbortError, `UnsupportedThinkingError`,
* `UnsupportedToolChoiceError` or `UnsafeThinkingReplayError` (a
* request the adapter refused before sending) counts. Override to ignore client
* errors (e.g., 4xx) so a malformed request doesn't trip the
* breaker for everyone.
*/
readonly shouldCount?: (error: unknown) => boolean;
/**
* Hook invoked on every state transition — your own callback, fired
* whether or not a run is in flight, and therefore the right place
* for a Redis-backed counter or a cluster-wide coordinator.
*
* Since 9.32 it is no longer the ONLY way to see a trip. Every
* transition is ALSO reported through the per-call `LLMCallHooks`
* channel as a `'circuit-changed'` `ResilienceReport`, which the
* in-run call site turns into
* `agentfootprint.error.circuit_changed` with the run's real
* correlation ids — the same seam `withFallback` and `withRetry`
* have always used. The two are complements, not duplicates: this
* hook fires everywhere, the event fires inside a run.
*/
readonly onStateChange?: (state: CircuitState, reason: string) => void;
}
export type CircuitState = 'closed' | 'open' | 'half-open';
/**
* 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.
*/
export declare class CircuitOpenError extends Error {
readonly code: "ERR_CIRCUIT_OPEN";
/** The error that tripped the breaker (or the most recent failure
* during HALF-OPEN that re-opened it). */
readonly cause: unknown;
/** Wall-clock timestamp at which the breaker may next probe. */
readonly retryAfter: number;
constructor(providerName: string, cause: unknown, retryAfter: number);
}
/**
* 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 })),
* );
* ```
*/
export declare function withCircuitBreaker(inner: LLMProvider, options?: WithCircuitBreakerOptions): LLMProvider;
//# sourceMappingURL=withCircuitBreaker.d.ts.map