Session 22: Tracker-driven quota guard, configurable cache TTL (1hr default), opt-in odds prewarmer (1505 tests)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user