Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+136
View File
@@ -0,0 +1,136 @@
/**
* 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 };