124 lines
3.2 KiB
JavaScript
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 };
|