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:
@@ -256,11 +256,18 @@ describe('quotaTracker ntfy alerts (Session 21)', () => {
|
||||
test('posts BLOCK to ntfy at 95% with priority 5', async () => {
|
||||
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
// Seed silently under WARN, then let a recordCall cross 95%.
|
||||
await tracker.syncFromHeaders('odds-api', {
|
||||
'x-requests-used': '474',
|
||||
'x-requests-remaining': '26',
|
||||
'x-requests-used': '390',
|
||||
'x-requests-remaining': '110',
|
||||
});
|
||||
await flushAsync();
|
||||
expect(axios.post).toHaveBeenCalledTimes(0); // seed at 78% is silent
|
||||
// Jump straight to 95% via the authoritative header sync — it must page.
|
||||
await tracker.syncFromHeaders('odds-api', {
|
||||
'x-requests-used': '475',
|
||||
'x-requests-remaining': '25',
|
||||
});
|
||||
await tracker.recordCall('odds-api'); // 475/500 → 95%
|
||||
await flushAsync();
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const [, body, opts] = axios.post.mock.calls[0];
|
||||
@@ -270,6 +277,26 @@ describe('quotaTracker ntfy alerts (Session 21)', () => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('syncFromHeaders (authoritative) pages on a header-only crossing — the silent-drain fix', async () => {
|
||||
// The 500/500 drain came in entirely via header reconcile; recordCall was
|
||||
// never on the burner's path, so the counter went to 100% unpaged. The
|
||||
// authoritative writer must alert. Seed silent, then cross 80% via sync.
|
||||
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await tracker.syncFromHeaders('odds-api', { 'x-requests-used': '390', 'x-requests-remaining': '110' });
|
||||
await flushAsync();
|
||||
expect(axios.post).toHaveBeenCalledTimes(0);
|
||||
await tracker.syncFromHeaders('odds-api', { 'x-requests-used': '400', 'x-requests-remaining': '100' }); // 80%
|
||||
await flushAsync();
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
expect(axios.post.mock.calls[0][2].headers.Priority).toBe('4');
|
||||
// A later sync in the same period is deduped — no alert storm.
|
||||
await tracker.syncFromHeaders('odds-api', { 'x-requests-used': '410', 'x-requests-remaining': '90' });
|
||||
await flushAsync();
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('dedupes — second recordCall in the same period does not re-post', async () => {
|
||||
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
@@ -287,26 +314,27 @@ describe('quotaTracker ntfy alerts (Session 21)', () => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('WARN→BLOCK transition fires BOTH alerts (separate dedupe keys)', async () => {
|
||||
test('WARN then BLOCK each fire once across a period (separate dedupe keys)', async () => {
|
||||
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
// Seed at 79%; first recordCall → 80% (WARN).
|
||||
// Seed at 78% (silent).
|
||||
await tracker.syncFromHeaders('odds-api', {
|
||||
'x-requests-used': '395',
|
||||
'x-requests-remaining': '105',
|
||||
'x-requests-used': '390',
|
||||
'x-requests-remaining': '110',
|
||||
});
|
||||
await tracker.recordCall('odds-api'); // 396 → 79.2% — under warn
|
||||
await tracker.recordCall('odds-api'); // 391 → 78.2% — under warn
|
||||
await flushAsync();
|
||||
expect(axios.post).toHaveBeenCalledTimes(0);
|
||||
// Jump to 95% — should fire BLOCK even though WARN never fired.
|
||||
await tracker.syncFromHeaders('odds-api', {
|
||||
'x-requests-used': '474',
|
||||
'x-requests-remaining': '26',
|
||||
});
|
||||
await tracker.recordCall('odds-api'); // 475 → 95% — BLOCK
|
||||
// Cross 80% → WARN (priority 4).
|
||||
await tracker.syncFromHeaders('odds-api', { 'x-requests-used': '400', 'x-requests-remaining': '100' });
|
||||
await flushAsync();
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
expect(axios.post.mock.calls[0][2].headers.Priority).toBe('5');
|
||||
expect(axios.post.mock.calls[0][2].headers.Priority).toBe('4');
|
||||
// Cross 95% → BLOCK (priority 5), a SEPARATE dedupe key so it still fires.
|
||||
await tracker.syncFromHeaders('odds-api', { 'x-requests-used': '475', 'x-requests-remaining': '25' });
|
||||
await flushAsync();
|
||||
expect(axios.post).toHaveBeenCalledTimes(2);
|
||||
expect(axios.post.mock.calls[1][2].headers.Priority).toBe('5');
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user