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