/** * 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. */ 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. This is the ONLY way to * observe breaker state: there is no declared event for a breaker * transition, so unlike `withFallback`/`withRetry` this decorator * reports nothing through the in-run `LLMCallHooks` channel. A trip * becomes visible in a trace only when composed under * `withFallback`, whose `agentfootprint.fallback.triggered` carries * the `CircuitOpenError` message as its `reason`. */ 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/llm-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