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