/** * Per-route circuit breaker (provider × network). Purely in-memory: each API process keeps * its own view, which is acceptable because the health probe loop re-syncs status into * `provider_health` for the admin dashboard. */ export type CircuitState = "closed" | "open" | "half_open"; interface Circuit { state: CircuitState; failures: number; successes: number; window: Array<{ t: number; ok: boolean }>; openedAt: number | null; nextProbeAt: number | null; } export interface CircuitOptions { /** Failure ratio in the rolling window that opens the circuit. */ failureThreshold: number; /** Minimum samples before the ratio is considered. */ minSamples: number; /** Rolling window length in ms. */ windowMs: number; /** How long to keep the circuit open before allowing a probe. */ cooldownMs: number; } const DEFAULTS: CircuitOptions = { failureThreshold: 0.6, minSamples: 8, windowMs: 120_000, cooldownMs: 60_000, }; export class CircuitBreaker { private readonly circuits = new Map(); private readonly opts: CircuitOptions; constructor(opts: Partial = {}) { this.opts = { ...DEFAULTS, ...opts }; } private get(key: string): Circuit { let c = this.circuits.get(key); if (!c) { c = { state: "closed", failures: 0, successes: 0, window: [], openedAt: null, nextProbeAt: null }; this.circuits.set(key, c); } return c; } /** Whether a request may be routed through this key right now. */ allow(key: string, now = Date.now()): boolean { const c = this.get(key); if (c.state === "closed") return true; if (c.state === "open") { if (c.nextProbeAt !== null && now >= c.nextProbeAt) { c.state = "half_open"; return true; } return false; } // half_open: allow a single probe at a time (approximation: allow, next failure re-opens) return true; } record(key: string, ok: boolean, now = Date.now()): CircuitState { const c = this.get(key); c.window.push({ t: now, ok }); const cutoff = now - this.opts.windowMs; while (c.window.length && c.window[0]!.t < cutoff) c.window.shift(); c.failures = c.window.filter((w) => !w.ok).length; c.successes = c.window.length - c.failures; if (c.state === "half_open") { if (ok) { c.state = "closed"; c.window = []; c.openedAt = null; c.nextProbeAt = null; } else { c.state = "open"; c.openedAt = now; c.nextProbeAt = now + this.opts.cooldownMs; } return c.state; } if (c.state === "closed" && c.window.length >= this.opts.minSamples) { const ratio = c.failures / c.window.length; if (ratio >= this.opts.failureThreshold) { c.state = "open"; c.openedAt = now; c.nextProbeAt = now + this.opts.cooldownMs; } } return c.state; } state(key: string): CircuitState { return this.get(key).state; } /** Health factor 0..1 used by the routing score. */ healthFactor(key: string): number { const c = this.get(key); if (c.state === "open") return 0; if (c.state === "half_open") return 0.3; if (c.window.length === 0) return 1; return Math.max(0, 1 - c.failures / c.window.length); } snapshot(): Array<{ key: string; state: CircuitState; failures: number; successes: number; openedAt: number | null }> { return [...this.circuits.entries()].map(([key, c]) => ({ key, state: c.state, failures: c.failures, successes: c.successes, openedAt: c.openedAt })); } reset(key?: string): void { if (key) this.circuits.delete(key); else this.circuits.clear(); } }