Files
vyndr/src/services/rateLimiter.js
T

102 lines
3.2 KiB
JavaScript

/**
* ARCH-2 (Session 7e): LEGACY rate limiter. The canonical implementation
* lives in src/utils/rateLimiter.js (factory pattern, paired with
* createCircuitBreaker). 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
*
* Token bucket rate limiter, per upstream key.
*
* Each upstream (ESPN, Pinnacle, DK, etc.) gets its own bucket so a runaway
* caller on one source doesn't starve the others. Buckets refill on demand,
* so we don't pay for setInterval timers.
*
* Usage:
* const limiter = require('./rateLimiter');
* await limiter.take('espn', 1); // throws if the wait would exceed maxWaitMs
*/
const DEFAULT_LIMIT = { capacity: 10, refillPerSec: 5 };
// Tunable per source. Numbers chosen to stay well under each upstream's
// observed rate limits.
const LIMITS = Object.freeze({
espn: { capacity: 30, refillPerSec: 10 },
pinnacle: { capacity: 10, refillPerSec: 3 },
draftkings: { capacity: 10, refillPerSec: 2 },
fanduel: { capacity: 10, refillPerSec: 2 },
betmgm: { capacity: 10, refillPerSec: 2 },
caesars: { capacity: 10, refillPerSec: 2 },
prizepicks: { capacity: 10, refillPerSec: 2 },
covers: { capacity: 5, refillPerSec: 1 },
rotowire: { capacity: 5, refillPerSec: 1 },
weather: { capacity: 20, refillPerSec: 5 },
injuries: { capacity: 20, refillPerSec: 5 },
'nba-stats': { capacity: 8, refillPerSec: 1 }, // stats.nba.com is strict
pybaseball: { capacity: 4, refillPerSec: 0.5 }, // Statcast tolerates roughly 1 req / 2s
});
const buckets = new Map();
function getBucket(key) {
let b = buckets.get(key);
if (!b) {
const cfg = LIMITS[key] || DEFAULT_LIMIT;
b = {
tokens: cfg.capacity,
capacity: cfg.capacity,
refillPerSec: cfg.refillPerSec,
lastRefillMs: Date.now(),
};
buckets.set(key, b);
}
return b;
}
function refill(b) {
const now = Date.now();
const elapsedSec = (now - b.lastRefillMs) / 1000;
if (elapsedSec <= 0) return;
b.tokens = Math.min(b.capacity, b.tokens + elapsedSec * b.refillPerSec);
b.lastRefillMs = now;
}
/**
* Wait for `cost` tokens on the named bucket. Resolves once tokens are
* consumed. Rejects if the projected wait exceeds maxWaitMs (default 5s).
*/
async function take(key, cost = 1, maxWaitMs = 5_000) {
const b = getBucket(key);
while (true) {
refill(b);
if (b.tokens >= cost) {
b.tokens -= cost;
return;
}
const needed = cost - b.tokens;
const waitMs = Math.ceil((needed / b.refillPerSec) * 1000);
if (waitMs > maxWaitMs) {
const err = new Error(`rate limit wait exceeded for ${key}`);
err.code = 'RATE_LIMIT_TIMEOUT';
err.retryAfterMs = waitMs;
throw err;
}
await new Promise((r) => setTimeout(r, Math.min(waitMs, 250)));
}
}
function snapshot() {
const out = {};
for (const [k, b] of buckets.entries()) {
refill(b);
out[k] = { tokens: Math.floor(b.tokens), capacity: b.capacity, refillPerSec: b.refillPerSec };
}
return out;
}
module.exports = { take, snapshot, LIMITS };