Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user