137 lines
4.1 KiB
JavaScript
137 lines
4.1 KiB
JavaScript
/**
|
|
* Token-bucket limiter + circuit breaker, factory-style.
|
|
*
|
|
* Why this exists alongside src/services/rateLimiter.js:
|
|
* - services/rateLimiter.js is a SHARED registry of upstream-keyed buckets
|
|
* ('espn', 'fanduel', etc.) used by the original odds adapters.
|
|
* - This file is a PER-INSTANCE factory used by the new pipeline adapters
|
|
* (SharpAPI, OddsPapi) where each adapter owns its own bucket and
|
|
* circuit breaker, paired together so a 5xx storm flips the breaker
|
|
* without polluting other adapters.
|
|
*
|
|
* The two coexist; new code should prefer this file.
|
|
*/
|
|
|
|
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
|
|
|
|
function createLimiter({ tokensPerInterval, interval }) {
|
|
if (!(tokensPerInterval > 0) || !(interval > 0)) {
|
|
throw new Error('createLimiter requires positive tokensPerInterval and interval');
|
|
}
|
|
const refillPerMs = tokensPerInterval / interval;
|
|
let tokens = tokensPerInterval;
|
|
let lastRefill = Date.now();
|
|
|
|
function refill() {
|
|
const now = Date.now();
|
|
const elapsed = now - lastRefill;
|
|
if (elapsed <= 0) return;
|
|
tokens = Math.min(tokensPerInterval, tokens + elapsed * refillPerMs);
|
|
lastRefill = now;
|
|
}
|
|
|
|
async function waitForToken(timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (true) {
|
|
refill();
|
|
if (tokens >= 1) {
|
|
tokens -= 1;
|
|
return true;
|
|
}
|
|
if (Date.now() >= deadline) {
|
|
// CRITICAL: never block forever. A stuck limiter must not kill the
|
|
// poller — log loud, proceed, let the caller hit the upstream rate
|
|
// limit naturally if it's actually exhausted.
|
|
console.warn('[rateLimiter] token wait exceeded timeout, proceeding without token');
|
|
return false;
|
|
}
|
|
const needed = 1 - tokens;
|
|
const waitMs = Math.min(250, Math.ceil(needed / refillPerMs));
|
|
await new Promise((r) => setTimeout(r, Math.max(10, waitMs)));
|
|
}
|
|
}
|
|
|
|
function snapshot() {
|
|
refill();
|
|
return { tokens: Math.floor(tokens), capacity: tokensPerInterval, interval };
|
|
}
|
|
|
|
return { waitForToken, snapshot };
|
|
}
|
|
|
|
function createCircuitBreaker({ failureThreshold = 3, resetTimeout = 60_000 } = {}) {
|
|
// States:
|
|
// closed — calls flow through, failures accumulate
|
|
// open — calls reject immediately until resetTimeout elapses
|
|
// half_open — one test call permitted; success closes, failure re-opens
|
|
let state = 'closed';
|
|
let failures = 0;
|
|
let openedAt = 0;
|
|
let halfOpenInFlight = false;
|
|
|
|
function transitionIfReady() {
|
|
if (state === 'open' && Date.now() - openedAt >= resetTimeout) {
|
|
state = 'half_open';
|
|
halfOpenInFlight = false;
|
|
}
|
|
}
|
|
|
|
async function call(fn) {
|
|
transitionIfReady();
|
|
|
|
if (state === 'open') {
|
|
const err = new Error('circuit breaker is open');
|
|
err.code = 'CIRCUIT_OPEN';
|
|
throw err;
|
|
}
|
|
|
|
if (state === 'half_open') {
|
|
if (halfOpenInFlight) {
|
|
const err = new Error('circuit breaker is half-open (test in flight)');
|
|
err.code = 'CIRCUIT_HALF_OPEN_BUSY';
|
|
throw err;
|
|
}
|
|
halfOpenInFlight = true;
|
|
}
|
|
|
|
try {
|
|
const result = await fn();
|
|
// Success: close the circuit and clear the failure count.
|
|
failures = 0;
|
|
state = 'closed';
|
|
halfOpenInFlight = false;
|
|
return result;
|
|
} catch (err) {
|
|
halfOpenInFlight = false;
|
|
if (state === 'half_open') {
|
|
state = 'open';
|
|
openedAt = Date.now();
|
|
throw err;
|
|
}
|
|
failures += 1;
|
|
if (failures >= failureThreshold) {
|
|
state = 'open';
|
|
openedAt = Date.now();
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function snapshot() {
|
|
transitionIfReady();
|
|
return { state, failures, openedAt };
|
|
}
|
|
|
|
return { call, snapshot };
|
|
}
|
|
|
|
const API_BUDGETS = Object.freeze({
|
|
sharpApi: { tokensPerInterval: 10, interval: 60_000 },
|
|
espn: { tokensPerInterval: 2, interval: 60_000 },
|
|
mlbStats: { tokensPerInterval: 2, interval: 60_000 },
|
|
oddsPapi: { tokensPerInterval: 5, interval: 60_000 },
|
|
openRouter: { tokensPerInterval: 15, interval: 60_000 },
|
|
});
|
|
|
|
module.exports = { createLimiter, createCircuitBreaker, API_BUDGETS };
|