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