/** * 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 '../adapters/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 AbortError 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