Files
vyndr/src/services/circuitBreaker.js
T

124 lines
3.2 KiB
JavaScript

/**
* 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 };