/** * ARCH-2 (Session 7e): LEGACY circuit breaker. The canonical implementation * lives in src/utils/rateLimiter.js (createCircuitBreaker factory). * New code should import from there. * * Current callers of this legacy module (remove this file when the list * empties): * - src/services/UnifiedOddsProvider.js * - src/services/adapters/ESPNAdapter.js * - src/services/adapters/PinnacleAdapter.js * * Per-upstream circuit breaker. * * States: * CLOSED — calls flow normally * OPEN — calls short-circuit instantly with BreakerOpenError * HALF_OPEN — one trial call allowed; success closes, failure re-opens * * Tunable per key; defaults are conservative (open after 5 fails in 60s, * stay open for 30s). Set per-source thresholds in OVERRIDES. */ const STATES = Object.freeze({ CLOSED: 'CLOSED', OPEN: 'OPEN', HALF_OPEN: 'HALF_OPEN' }); const DEFAULTS = { failureThreshold: 5, windowMs: 60_000, cooldownMs: 30_000, }; const OVERRIDES = Object.freeze({ pinnacle: { failureThreshold: 3, cooldownMs: 60_000 }, 'nba-stats': { failureThreshold: 4, cooldownMs: 45_000 }, pybaseball: { failureThreshold: 3, cooldownMs: 60_000 }, }); class BreakerOpenError extends Error { constructor(key, retryAt) { super(`circuit open for ${key}`); this.name = 'BreakerOpenError'; this.code = 'BREAKER_OPEN'; this.upstream = key; this.retryAt = retryAt; } } const breakers = new Map(); function getBreaker(key) { let b = breakers.get(key); if (!b) { const cfg = { ...DEFAULTS, ...(OVERRIDES[key] || {}) }; b = { key, state: STATES.CLOSED, failures: [], openedAt: 0, cfg, }; breakers.set(key, b); } return b; } function pruneFailures(b, now) { const cutoff = now - b.cfg.windowMs; while (b.failures.length && b.failures[0] < cutoff) b.failures.shift(); } /** * Run `fn` under the breaker. If the breaker is OPEN, throws immediately. * Counts thrown errors as failures, except those marked `err.skipBreaker`. */ async function call(key, fn) { const b = getBreaker(key); const now = Date.now(); if (b.state === STATES.OPEN) { const reopenAt = b.openedAt + b.cfg.cooldownMs; if (now < reopenAt) throw new BreakerOpenError(key, reopenAt); // Cooldown elapsed — give one trial call. b.state = STATES.HALF_OPEN; } try { const result = await fn(); if (b.state === STATES.HALF_OPEN) { b.state = STATES.CLOSED; b.failures.length = 0; } return result; } catch (err) { if (!err || !err.skipBreaker) { b.failures.push(Date.now()); pruneFailures(b, Date.now()); if (b.state === STATES.HALF_OPEN || b.failures.length >= b.cfg.failureThreshold) { b.state = STATES.OPEN; b.openedAt = Date.now(); } } throw err; } } function snapshot() { const out = {}; for (const [k, b] of breakers.entries()) { pruneFailures(b, Date.now()); out[k] = { state: b.state, failures: b.failures.length, cooldownEndsAt: b.state === STATES.OPEN ? b.openedAt + b.cfg.cooldownMs : null, }; } return out; } function reset(key) { if (key) breakers.delete(key); else breakers.clear(); } module.exports = { call, snapshot, reset, BreakerOpenError, STATES };