SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
3.6 KB · 121 lines typescript
Raw Blame History
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