Session 22: Tracker-driven quota guard, configurable cache TTL (1hr default), opt-in odds prewarmer (1505 tests)

This commit is contained in:
Kev
2026-06-12 02:41:51 -04:00
parent ea848e327e
commit 6ab49d4c37
7 changed files with 587 additions and 9 deletions
+49 -4
View File
@@ -9,7 +9,34 @@ const { normalizeProps, extractSpreads, MARKET_MAP } = require('../utils/oddsNor
const gateway = require('./providerGateway');
const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
const CACHE_TTL = 900; // 15 minutes in seconds
// Session 22 — cache TTL is the dominant lever on monthly odds-api
// credit spend. Each cache miss fans out to (1 + N) upstream calls
// (1 events lookup + 1 per game-day event). At the free tier's
// 500 credits/month, the previous 15-min TTL was an order of
// magnitude too aggressive for live serving across multiple sports.
//
// Default raised to 60 minutes; operators can override via
// `ODDS_CACHE_TTL_SECONDS` from Coolify without a redeploy:
// ODDS_CACHE_TTL_SECONDS=3600 → 1 hour (default)
// ODDS_CACHE_TTL_SECONDS=7200 → 2 hours (free tier with 4+ sports)
// ODDS_CACHE_TTL_SECONDS=900 → 15 min (legacy, only on paid tier)
const DEFAULT_CACHE_TTL = 3600; // 1h
function getConfiguredCacheTTL() {
const raw = process.env.ODDS_CACHE_TTL_SECONDS;
if (!raw) return DEFAULT_CACHE_TTL;
const n = Number.parseInt(raw, 10);
// Defensive — refuse to bypass cache entirely (very_low_ttl < 60s
// would shred credits). Same upper bound (1 day) prevents a
// typo from holding stale data forever.
if (!Number.isFinite(n) || n < 60 || n > 86400) return DEFAULT_CACHE_TTL;
return n;
}
// Kept as a top-level binding for backward compat with the existing
// `module.exports.CACHE_TTL` consumers (tests + the route layer
// probe). Resolved at module load so deploys that change the env
// var require a restart — same contract as every other env-driven
// constant in the codebase.
const CACHE_TTL = getConfiguredCacheTTL();
// Sport identifiers consumed by getOdds → mapped to the odds-api.com
// sport key. Soccer leagues are listed individually so the route layer
// can fetch per-league without changing the upstream contract. Only
@@ -273,11 +300,26 @@ async function getOdds(sport) {
};
}
// Check quota before making API call
const currentQuota = await getQuotaRemaining(redis);
if (currentQuota != null && currentQuota <= 0) {
// Session 22 — pre-flight quota check now reads from the
// Session 20 tracker (truth source: synced from upstream
// response headers on every call). The legacy
// `getQuotaRemaining(redis)` read a separate Redis hash that
// drifted (Chrome Claude found it showing 46 remaining while
// reality was 7) because that hash was only updated by THIS
// file's `updateQuota` — gateway calls and other callers
// bypassed it. The tracker is updated by both the gateway and
// updateQuota, so it can't lag behind.
//
// Threshold: tracker BLOCKs at >=95%. Below that, we still
// proceed (matches legacy behavior of allowing up to the very
// last credit). Degraded-mode (Redis down) fails OPEN — see
// quotaTracker.js for the rationale.
const quotaTracker = require('./quotaTracker');
const quotaStatus = await quotaTracker.getQuotaStatus('odds-api');
if (!quotaStatus.allowed) {
const error = new Error('Odds data temporarily unavailable. Try again later.');
error.statusCode = 429;
error.quotaStatus = quotaStatus;
throw error;
}
@@ -372,4 +414,7 @@ module.exports = {
updateQuota,
getQuotaRemaining,
CACHE_TTL,
// Session 22 — exposed for tests that exercise env-driven TTL
// resolution without re-loading the module.
getConfiguredCacheTTL,
};