TypeScript 97.5%
SQL 1.4%
Python 0.8%
1/**2 * Per-route circuit breaker (provider × network). Purely in-memory: each API process keeps3 * its own view, which is acceptable because the health probe loop re-syncs status into4 * `provider_health` for the admin dashboard.5 */6export type CircuitState = "closed" | "open" | "half_open";78interface Circuit {9 state: CircuitState;10 failures: number;11 successes: number;12 window: Array<{ t: number; ok: boolean }>;13 openedAt: number | null;14 nextProbeAt: number | null;15}1617export interface CircuitOptions {18 /** Failure ratio in the rolling window that opens the circuit. */19 failureThreshold: number;20 /** Minimum samples before the ratio is considered. */21 minSamples: number;22 /** Rolling window length in ms. */23 windowMs: number;24 /** How long to keep the circuit open before allowing a probe. */25 cooldownMs: number;26}2728const DEFAULTS: CircuitOptions = {29 failureThreshold: 0.6,30 minSamples: 8,31 windowMs: 120_000,32 cooldownMs: 60_000,33};3435export class CircuitBreaker {36 private readonly circuits = new Map<string, Circuit>();37 private readonly opts: CircuitOptions;3839 constructor(opts: Partial<CircuitOptions> = {}) {40 this.opts = { ...DEFAULTS, ...opts };41 }4243 private get(key: string): Circuit {44 let c = this.circuits.get(key);45 if (!c) {46 c = { state: "closed", failures: 0, successes: 0, window: [], openedAt: null, nextProbeAt: null };47 this.circuits.set(key, c);48 }49 return c;50 }5152 /** Whether a request may be routed through this key right now. */53 allow(key: string, now = Date.now()): boolean {54 const c = this.get(key);55 if (c.state === "closed") return true;56 if (c.state === "open") {57 if (c.nextProbeAt !== null && now >= c.nextProbeAt) {58 c.state = "half_open";59 return true;60 }61 return false;62 }63 // half_open: allow a single probe at a time (approximation: allow, next failure re-opens)64 return true;65 }6667 record(key: string, ok: boolean, now = Date.now()): CircuitState {68 const c = this.get(key);69 c.window.push({ t: now, ok });70 const cutoff = now - this.opts.windowMs;71 while (c.window.length && c.window[0]!.t < cutoff) c.window.shift();72 c.failures = c.window.filter((w) => !w.ok).length;73 c.successes = c.window.length - c.failures;7475 if (c.state === "half_open") {76 if (ok) {77 c.state = "closed";78 c.window = [];79 c.openedAt = null;80 c.nextProbeAt = null;81 } else {82 c.state = "open";83 c.openedAt = now;84 c.nextProbeAt = now + this.opts.cooldownMs;85 }86 return c.state;87 }88 if (c.state === "closed" && c.window.length >= this.opts.minSamples) {89 const ratio = c.failures / c.window.length;90 if (ratio >= this.opts.failureThreshold) {91 c.state = "open";92 c.openedAt = now;93 c.nextProbeAt = now + this.opts.cooldownMs;94 }95 }96 return c.state;97 }9899 state(key: string): CircuitState {100 return this.get(key).state;101 }102103 /** Health factor 0..1 used by the routing score. */104 healthFactor(key: string): number {105 const c = this.get(key);106 if (c.state === "open") return 0;107 if (c.state === "half_open") return 0.3;108 if (c.window.length === 0) return 1;109 return Math.max(0, 1 - c.failures / c.window.length);110 }111112 snapshot(): Array<{ key: string; state: CircuitState; failures: number; successes: number; openedAt: number | null }> {113 return [...this.circuits.entries()].map(([key, c]) => ({ key, state: c.state, failures: c.failures, successes: c.successes, openedAt: c.openedAt }));114 }115116 reset(key?: string): void {117 if (key) this.circuits.delete(key);118 else this.circuits.clear();119 }120}121