/** * 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 };