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:
@@ -20,6 +20,11 @@ jest.mock('../../src/services/quotaTracker', () => {
|
||||
}),
|
||||
rollback: jest.fn(async () => {}),
|
||||
syncFromHeaders: jest.fn(async () => null),
|
||||
getQuotaStatus: jest.fn(async (providerId) => {
|
||||
const s = state.get(providerId) || { allowed: true, used: 0, limit: 500 };
|
||||
const remaining = s.remaining != null ? s.remaining : (s.limit - s.used);
|
||||
return { provider: providerId, allowed: s.allowed, used: s.used, limit: s.limit, remaining, degraded: !!s.degraded };
|
||||
}),
|
||||
__state: state,
|
||||
__setStatus: setStatus,
|
||||
};
|
||||
@@ -154,3 +159,34 @@ describe('gateway.fetch — upstream errors', () => {
|
||||
expect(tracker.rollback).toHaveBeenCalledWith('odds-api');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gateway.fetch — reserve floor (quota guard)', () => {
|
||||
test('a DISCRETIONARY call (reserve>0) is refused while remaining <= reserve', async () => {
|
||||
// 470 used of 500 → 30 remaining, at or below a 50-credit reserve.
|
||||
tracker.__setStatus('odds-api', true, { used: 470, remaining: 30 });
|
||||
const cb = jest.fn(async () => ({ data: 'x' }));
|
||||
await expect(
|
||||
gateway.fetch('odds-api', cb, { capability: 'futures', sport: 'mlb', reserve: 50 }),
|
||||
).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' });
|
||||
expect(cb).not.toHaveBeenCalled(); // never spent the reserved credits
|
||||
expect(tracker.recordCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('an ESSENTIAL call (no reserve) still uses the same remaining credits', async () => {
|
||||
// Same 30 remaining, but the MLB prop-backup path passes no reserve — it may
|
||||
// spend down to the normal 95% block. This is the MLB-never-starved guarantee.
|
||||
tracker.__setStatus('odds-api', true, { used: 470, remaining: 30 });
|
||||
const cb = jest.fn(async () => ({ data: 'ok' }));
|
||||
const out = await gateway.fetch('odds-api', cb, { capability: 'odds', sport: 'mlb' });
|
||||
expect(out).toEqual({ data: 'ok' });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a discretionary call PROCEEDS when remaining is above the reserve', async () => {
|
||||
tracker.__setStatus('odds-api', true, { used: 300, remaining: 200 });
|
||||
const cb = jest.fn(async () => ({ data: 'ok' }));
|
||||
const out = await gateway.fetch('odds-api', cb, { capability: 'futures', sport: 'mlb', reserve: 50 });
|
||||
expect(out).toEqual({ data: 'ok' });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user