Quota guard: close the silent odds-api drain + reserve floor for MLB
Diagnosis (why 500/500 went unpaged): the only regular odds-api burner was
futuresService, which called axios DIRECTLY — bypassing the gateway, so it
never hit recordCall (the ONE place the WARN/BLOCK pager fires) and never
respected the 95% block. It only syncFromHeaders, which updated the counter's
number SILENTLY. oddsService (which does go through the gateway) only touches
odds-api when PropLine fails, so recordCall for odds-api effectively never ran.
Result: the counter could reach 100% with neither pager firing.
Fixes (a silent drain is now impossible, not just guarded):
- futuresService routes through gateway.fetch('odds-api', …) → counted, blocked
at 95%, and reserve-gated. Closes the raw-axios bypass.
- Reserve floor in the gateway: a DISCRETIONARY call (futures/soccer) passes
reserve=ODDS_API_RESERVE (default 50) and is refused while remaining <= reserve.
The ESSENTIAL MLB prop-backup passes no reserve and may spend to the 95% block.
→ a futures/soccer drain can NEVER starve MLB's backup path.
- quotaTracker.syncFromHeaders (the AUTHORITATIVE number) now fires the same
once-per-period WARN/BLOCK alert on a crossing — extracted fireThresholdAlert
shared with recordCall. The header-only drain now pages.
- POST /api/internal/quota/test-alert (internal-key) test-fires the pager
end-to-end so ntfy delivery is verifiable on demand.
Also (reality-corrected cadence): WNBA restored to the full grid. 2026-07-15
had two AFTERNOON WNBA games finished before the 22 UTC slot — 14 UTC (10am ET)
is the only slot early enough for a 1pm ET game's props, and on PropLine the
extra slots cost a rounding error. Soccer stays the only trimmed sport (the
real odds-api discipline). Assumption corrected by observed data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,13 @@ const { FUTURES_KEYS, ODDS_API_BASE } = require('./oddsService');
|
||||
const DEFAULT_TTL = 12 * 60 * 60; // 12h logical freshness (quota-disciplined).
|
||||
const PERSIST_TTL = 7 * 24 * 60 * 60; // 7d Redis persistence so prev survives the gap.
|
||||
const HTTP_TIMEOUT_MS = 12_000;
|
||||
// Credits held back for the ESSENTIAL path (MLB prop backup when PropLine
|
||||
// fails). Discretionary futures calls stop while ≤ this many odds-api credits
|
||||
// remain. Operator-tunable; default 50 of the 500/mo pool.
|
||||
const ODDS_API_RESERVE = (() => {
|
||||
const n = Number.parseInt(process.env.ODDS_API_RESERVE, 10);
|
||||
return Number.isFinite(n) && n >= 0 ? n : 50;
|
||||
})();
|
||||
// A price is "flat" unless the decimal payout moves by at least this much —
|
||||
// filters odds-jitter from a real steam/drift (mirrors DELTA_NOISE in shape).
|
||||
const MOVE_EPSILON = 0.05;
|
||||
@@ -180,17 +187,23 @@ async function getFutures(sport, deps = {}) {
|
||||
|
||||
const axios = deps.axios || require('axios');
|
||||
const base = deps.ODDS_API_BASE || ODDS_API_BASE;
|
||||
// Quota guard — futures is DISCRETIONARY, so it goes through the gateway
|
||||
// (counted via recordCall → the 80% pager sees it, and blocked at 95%) with a
|
||||
// RESERVE floor: it stops spending while ≤ ODDS_API_RESERVE credits remain, so
|
||||
// a futures drain can never starve the essential MLB prop-backup path. This
|
||||
// closes the raw-axios bypass that let futures burn odds-api invisibly (the
|
||||
// reason the 500/500 exhaustion went unpaged). The gateway also syncs headers.
|
||||
const gateway = deps.gateway || require('./providerGateway');
|
||||
const reserve = deps.reserve != null ? deps.reserve : ODDS_API_RESERVE;
|
||||
try {
|
||||
const res = await axios.get(`${base}/${futuresKey}/odds`, {
|
||||
params: { apiKey, regions: 'us', markets: 'outrights', oddsFormat: 'american' },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
// Best-effort quota sync (same headers the player-prop path reads).
|
||||
try {
|
||||
if (res && res.headers) {
|
||||
require('./quotaTracker').syncFromHeaders('odds-api', res.headers);
|
||||
}
|
||||
} catch (_) { /* quota tracking is a signal, never a dependency */ }
|
||||
const res = await gateway.fetch(
|
||||
'odds-api',
|
||||
() => axios.get(`${base}/${futuresKey}/odds`, {
|
||||
params: { apiKey, regions: 'us', markets: 'outrights', oddsFormat: 'american' },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
}),
|
||||
{ capability: 'futures', sport: sp, reserve, syncHeadersFrom: (r) => r && r.headers },
|
||||
);
|
||||
|
||||
const normalized = normalizeOutrights(res.data);
|
||||
const markets = attachMoves(normalized, cached && cached.markets);
|
||||
|
||||
Reference in New Issue
Block a user