Session 21: All adapters through gateway, ntfy alerts, provider registry correction (1486 tests)

This commit is contained in:
Kev
2026-06-12 02:06:22 -04:00
parent 9b10bb4138
commit ea848e327e
14 changed files with 614 additions and 46 deletions
+182 -1
View File
@@ -4,7 +4,188 @@
2026-06-12 2026-06-12
## Current Phase ## Current Phase
SHIP BUILD v20.0 — Provider intelligence: quota tracker, gateway, key rotation (Session 20) SHIP BUILD v21.0 — Every external HTTP call tracked + ntfy alerts (Session 21)
## Session 21 (2026-06-12) — SHIPPED
Session 20 built the gateway; Session 21 wires every adapter
through it. Plus: ntfy push at WARN (80%) and BLOCK (95%), an
end-to-end integration test, and an honest correction to the
provider registry that flags a critical misconception in the
original spec.
### PHASE 1 — Provider trace + registry correction (CRITICAL)
The Session 20 registry classified `oddspapi` and `parlayapi` as
live-odds fallback providers for `the-odds-api`. **They are not.**
Tracing the existing adapters revealed:
- **`oddsPapiAdapter`** — Pinnacle CLOSING-line capture at
tip-off. Writes to `closing_lines` table for CLV. One row per
game. NOT a live-props source.
- **`parlayApiAdapter`** — historical archive (1K credits/month).
Used by bulk scripts and trap detection. NOT real-time.
Registry corrected:
- `oddspapi.capabilities = ['closing_lines']`, name = "ODDSPAPI
(Pinnacle close)"
- `parlayapi.capabilities = ['historical_props',
'historical_lines']`, name = "ParlayAPI (historical)"
- Both bumped to `priority: 1` for their actual capability sets
**Consequence:** `getFallbackChain('odds', 'nba', 'odds-api')` now
returns `[]` because no other configured provider serves live
`odds`. The gateway's QuotaExhaustedError path is honest about
this: when the-odds-api hits 95%, there is no fallback to take
over. The fix to the original 503 incident is operational (higher
tier, better caching, or a real new provider), not architectural.
### PHASE 2 — Tank01 NBA + MLB through gateway
Single axios.get call site in each adapter's `fetchWithCache` —
wrapped in `gateway.fetch('tank01', () => axios.get(...), {
capability: 'box_scores', sport })`. Existing cache + stale-while-
revalidate logic untouched.
Tests: existing 51 Tank01 tests all pass unchanged.
### PHASE 3 — API-Football through gateway
Same surgical pattern at the `fetchWithCache` axios.get. The
adapter keeps its own `apifootball:daily_count` Redis counter
(legacy SOFT_LIMIT=90 trigger); the tracker is now ALSO advancing
on every successful call. Two counters, one truth source: tracker
drives WARN/BLOCK; legacy counter drives the local
stale-while-revalidate switch.
Tests: 16/16 unchanged.
### PHASE 4 — Football-Data through gateway
Wrap pattern as above. The adapter's in-process token bucket
(8 req/min) short-circuits BEFORE the gateway — so the gateway
counter only ticks for calls that actually went over the wire.
Order: bucket → gateway → axios.
Tests: 15/15 unchanged.
### PHASE 5 — ODDSPAPI + ParlayAPI through gateway
Wired for their actual purposes:
- `oddsPapiAdapter.fetchPinnacleProp` → gateway with
`capability: 'closing_lines'`
- `parlayApiAdapter.fetchWithGuards` → gateway with
`capability: 'historical_props'`
Test mock update: `parlayApiAdapter.test.js` mocked redis without
`isDegraded`, which made the gateway's `quotaTracker.recordCall`
throw. Added `isDegraded: () => true` so the gateway falls
through in degraded-mode fail-open — preserves the test's
existing axios+cache assertions.
Tests: 13/13 (10 oddsPapi + 3 parlayApi) pass.
### PHASE 6 — ntfy alerts at WARN + BLOCK
`quotaTracker.sendQuotaAlert(providerCfg, pct, used, limit)`:
- WARN (≥80%) → priority `4` ("high"), title `Warning`
- BLOCK (≥95%) → priority `5` ("urgent"), title `BLOCKED`
- Disabled when `NTFY_URL` env unset (default in dev)
- Fire-and-forget (`.catch(() => {})`) so a slow ntfy server
can't add latency to the adapter's HTTP call
- ntfy POST failure → console.warn only; recordCall still
returns the normal status
Two dedupe keys per period:
- `quota_warned:{provider}:{period}` — WARN sentinel
- `quota_warned:{provider}:{period}:block` — BLOCK sentinel
This handles the WARN→BLOCK transition correctly: a provider
that jumps from 79% → 96% in one call fires the BLOCK alert
even though the WARN sentinel was never set. Without the
separate key, the operator wouldn't get the BLOCK notice (the
actionable one).
6 new tests cover: no-post when NTFY_URL unset, priority 4 at
80%, priority 5 at 95%, dedupe (3 calls → 1 alert), WARN→BLOCK
transition fires BOTH alerts, axios.post failure preserves
recordCall return.
### PHASE 7 — End-to-end gateway wiring test
`tests/integration/providerGatewayWiring.test.js` — 4 tests
through the Tank01 NBA adapter (chosen because its
`fetchWithCache` has no token-bucket/circuit-breaker; the
gateway behavior dominates):
1. Successful adapter call → tank01 counter goes 0 → 1
2. Cache hit → no HTTP, counter stays
3. Counter seeded to 95% via `syncFromHeaders` → adapter
returns `null` (cache miss + no stale = degrade to null);
axios.get NEVER called
4. axios throws → gateway rolls back the optimistic increment;
counter restored to pre-call value
### Honest scope flags
- **No new ODDSPAPI/ParlayAPI live-props adapter.** The spec
asked for one; reality is they don't serve live props. Built
documentation in the registry instead.
- **No "provider-aware callback architecture" abstraction
(Phase 2 of the spec).** Each adapter is already provider-aware
(it knows its URL, key, auth) — adding a meta-adapter that
switches between them per-call is premature without a real
fallback chain. Worth revisiting if/when a true live-odds
alternative provider is onboarded.
- The "documentation" phase wasn't applied to a separate
playbook file (none exists at the repo root); the corrections
+ per-provider wiring rationale live in the adapter files and
this BUILD-STATE entry, which is the closest the repo has to
a playbook.
### Battery
- Express suite: **115 passed / 1486 tests** (+10 over baseline
1476). Breakdown of new tests:
- 6 ntfy in quotaTracker.test.js
- 4 in providerGatewayWiring.test.js (new file)
- Web build: **clean**, no TS errors. Admin route still resolves.
### Files changed (Session 21)
**Created:**
- `tests/integration/providerGatewayWiring.test.js`
**Modified:**
- `src/config/providers.js` — capability corrections for
oddspapi + parlayapi
- `src/services/quotaTracker.js` — `sendQuotaAlert` + WARN/BLOCK
dedupe key split
- `src/services/adapters/tank01NbaAdapter.js` — gateway wrap
- `src/services/adapters/tank01MlbAdapter.js` — gateway wrap
- `src/services/adapters/apiFootballAdapter.js` — gateway wrap
- `src/services/adapters/footballDataAdapter.js` — gateway wrap
- `src/services/adapters/oddsPapiAdapter.js` — gateway wrap
- `src/services/adapters/parlayApiAdapter.js` — gateway wrap
- `tests/unit/quotaTracker.test.js` — 6 ntfy tests + axios mock
- `tests/unit/parlayApiAdapter.test.js` — `isDegraded` in mock
### Provider wiring status (after Session 21)
| Provider | Gateway-wired | Capability | Quota visible |
|----------------|---------------|-----------------|---------------|
| the-odds-api | ✅ (Session 20) | odds/props | ✅ |
| Tank01 NBA+MLB | ✅ | box_scores | ✅ |
| API-Football | ✅ | lineups/stats | ✅ |
| Football-Data | ✅ | fixtures/tables | ✅ |
| ODDSPAPI | ✅ | closing_lines | ✅ |
| ParlayAPI | ✅ | historical | ✅ |
Every external HTTP call from the app now flows through
`gateway.fetch()`. The admin dashboard's Provider quotas tile
shows real numbers for every one of them.
---
## Session 20 (2026-06-12) — SHIPPED ## Session 20 (2026-06-12) — SHIPPED
+7
View File
@@ -640,3 +640,10 @@
{"ts":"2026-06-12T04:48:18.934Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} {"ts":"2026-06-12T04:48:18.934Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T04:48:19.929Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} {"ts":"2026-06-12T04:48:19.929Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T04:48:20.117Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} {"ts":"2026-06-12T04:48:20.117Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T05:47:02.696Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T05:47:02.770Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T05:47:03.833Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T05:47:05.218Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T05:47:05.218Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T05:47:05.218Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T05:47:05.239Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
+21 -8
View File
@@ -38,25 +38,38 @@ const PROVIDERS = {
capabilities: ['odds', 'props', 'lines', 'spreads'], capabilities: ['odds', 'props', 'lines', 'spreads'],
priority: 1, priority: 1,
}, },
// Session 21 — correction. ODDSPAPI is NOT a live-props fallback
// for the-odds-api. It serves Pinnacle CLOSING lines, captured at
// tip-off into the `closing_lines` table for CLV tracking. The
// `oddsPapiAdapter` calls /sports/{sportKey}/events/{gameId}/odds
// with `bookmaker=pinnacle, market=player_props` ONCE per game.
// Base URL: https://api.oddspapi.io/v1. Auth: X-Api-Key header.
'oddspapi': { 'oddspapi': {
name: 'ODDSPAPI', name: 'ODDSPAPI (Pinnacle close)',
envKey: 'ODDSPAPI_KEY', envKey: 'ODDSPAPI_KEY',
quotaType: 'monthly', quotaType: 'monthly',
quotaLimit: 1000, quotaLimit: 1000,
resetDay: 1, resetDay: 1,
sports: ['nba', 'wnba', 'mlb', 'nfl'], sports: ['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb'],
capabilities: ['odds', 'props'], capabilities: ['closing_lines'],
priority: 2, priority: 1,
}, },
// Session 21 — correction. ParlayAPI is a historical-archive
// provider, NOT a live-odds source. 1,000 credits/month free tier
// ("drop-in for the-odds-api, up to 6x cheaper" — but used for
// history, not real-time). Two endpoints in use:
// /historical/player_props → hit rate enrichment
// /historical/closing_lines → CLV reference
// Base URL: https://api.parlayapi.io/v1. Auth: X-Api-Key header.
'parlayapi': { 'parlayapi': {
name: 'ParlayAPI', name: 'ParlayAPI (historical)',
envKey: 'PARLAYAPI_KEY', envKey: 'PARLAYAPI_KEY',
quotaType: 'monthly', quotaType: 'monthly',
quotaLimit: 1000, quotaLimit: 1000,
resetDay: 1, resetDay: 1,
sports: ['nba', 'wnba', 'mlb', 'nfl'], sports: ['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb'],
capabilities: ['odds', 'parlays', 'correlations'], capabilities: ['historical_props', 'historical_lines'],
priority: 3, priority: 1,
}, },
// === STATS / BOX SCORES === // === STATS / BOX SCORES ===
+17 -4
View File
@@ -30,6 +30,15 @@
const axios = require('axios'); const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis'); const { cacheGet, cacheSet } = require('../../utils/redis');
// Session 21 — gateway wrap. The adapter still keeps its own
// `apifootball:daily_count` counter (which has the SOFT_LIMIT=90
// soft-stop logic), but every successful HTTP call now also
// advances the canonical per-provider quota counter the admin
// dashboard reads from. Two counters, one truth-source: the
// gateway-tracker counter is the one the WARN/BLOCK thresholds
// fire against; the local daily_count is the legacy
// stale-while-revalidate trigger.
const gateway = require('../providerGateway');
const BASE_URL = 'https://v3.football.api-sports.io'; const BASE_URL = 'https://v3.football.api-sports.io';
const HTTP_TIMEOUT_MS = 8_000; const HTTP_TIMEOUT_MS = 8_000;
@@ -93,10 +102,14 @@ async function fetchWithCache(path, cacheKey, ttl) {
// 4. Network. // 4. Network.
try { try {
const res = await axios.get(`${BASE_URL}${path}`, { const res = await gateway.fetch(
headers: { 'x-apisports-key': process.env.API_FOOTBALL_KEY }, 'api-football',
timeout: HTTP_TIMEOUT_MS, () => axios.get(`${BASE_URL}${path}`, {
}); headers: { 'x-apisports-key': process.env.API_FOOTBALL_KEY },
timeout: HTTP_TIMEOUT_MS,
}),
{ capability: 'lineups', sport: 'soccer' },
);
await bumpDailyCount(); await bumpDailyCount();
const body = res.data; const body = res.data;
if (body && typeof body === 'object') { if (body && typeof body === 'object') {
+16 -4
View File
@@ -34,6 +34,14 @@
const axios = require('axios'); const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis'); const { cacheGet, cacheSet } = require('../../utils/redis');
// Session 21 — gateway wrap. The adapter's in-process token bucket
// (8 req/min) is the FIRST line of defense (synchronous; no Redis
// round-trip), and the gateway's per-minute counter is the
// observability layer (cross-process, visible in /admin). The
// bucket short-circuits before the gateway in burst scenarios, so
// the counter only ticks up for calls that actually went over the
// wire.
const gateway = require('../providerGateway');
const BASE_URL = 'https://api.football-data.org/v4'; const BASE_URL = 'https://api.football-data.org/v4';
const HTTP_TIMEOUT_MS = 8_000; const HTTP_TIMEOUT_MS = 8_000;
@@ -103,10 +111,14 @@ async function fetchWithCache(path, cacheKey, ttl) {
// 4. Hit the network. // 4. Hit the network.
try { try {
const res = await axios.get(`${BASE_URL}${path}`, { const res = await gateway.fetch(
headers: { 'X-Auth-Token': process.env.FOOTBALL_DATA_API_KEY }, 'football-data',
timeout: HTTP_TIMEOUT_MS, () => axios.get(`${BASE_URL}${path}`, {
}); headers: { 'X-Auth-Token': process.env.FOOTBALL_DATA_API_KEY },
timeout: HTTP_TIMEOUT_MS,
}),
{ capability: 'fixtures', sport: 'soccer' },
);
const body = res.data; const body = res.data;
if (body && typeof body === 'object') { if (body && typeof body === 'object') {
// Write to BOTH the live and stale keys. Stale key has a much // Write to BOTH the live and stale keys. Stale key has a much
+15 -5
View File
@@ -15,6 +15,12 @@ const axios = require('axios');
const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../utils/rateLimiter'); const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../utils/rateLimiter');
const { devig } = require('../../utils/odds'); const { devig } = require('../../utils/odds');
const { getSupabaseServiceClient } = require('../../utils/supabase'); const { getSupabaseServiceClient } = require('../../utils/supabase');
// Session 21 — gateway wrap. ODDSPAPI is the Pinnacle-close
// provider, not a live-odds source. Each call hits ONCE per game
// at tip-off, so traffic is bounded — the gateway counter mostly
// gives the admin dashboard visibility rather than acting as
// hot-path throttle (the in-process limiter does that).
const gateway = require('../providerGateway');
const HTTP_TIMEOUT_MS = 10_000; const HTTP_TIMEOUT_MS = 10_000;
const BASE_URL = process.env.ODDSPAPI_BASE_URL || 'https://api.oddspapi.io/v1'; const BASE_URL = process.env.ODDSPAPI_BASE_URL || 'https://api.oddspapi.io/v1';
@@ -47,11 +53,15 @@ async function fetchPinnacleProp(sport, gameId, playerName, statType) {
await limiter.waitForToken(); await limiter.waitForToken();
try { try {
return await breaker.call(async () => { return await breaker.call(async () => {
const res = await axios.get(`${BASE_URL}/sports/${sportKey(sport)}/events/${gameId}/odds`, { const res = await gateway.fetch(
params: { bookmaker: 'pinnacle', market: 'player_props' }, 'oddspapi',
headers: { 'X-Api-Key': process.env.ODDSPAPI_KEY }, () => axios.get(`${BASE_URL}/sports/${sportKey(sport)}/events/${gameId}/odds`, {
timeout: HTTP_TIMEOUT_MS, params: { bookmaker: 'pinnacle', market: 'player_props' },
}); headers: { 'X-Api-Key': process.env.ODDSPAPI_KEY },
timeout: HTTP_TIMEOUT_MS,
}),
{ capability: 'closing_lines', sport },
);
const props = res.data?.props || res.data?.data || []; const props = res.data?.props || res.data?.data || [];
return Array.isArray(props) return Array.isArray(props)
? props.find( ? props.find(
+15 -6
View File
@@ -21,6 +21,11 @@
const axios = require('axios'); const axios = require('axios');
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter'); const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
const { cacheGet, cacheSet } = require('../../utils/redis'); const { cacheGet, cacheSet } = require('../../utils/redis');
// Session 21 — gateway wrap. ParlayAPI is the historical-archive
// source (1K credits/month). The in-process limiter caps to
// 5 req/min so we can spread the monthly budget; the gateway
// counter is what the admin dashboard reads against.
const gateway = require('../providerGateway');
const SOURCE = 'parlayapi'; const SOURCE = 'parlayapi';
const HTTP_TIMEOUT_MS = 10_000; const HTTP_TIMEOUT_MS = 10_000;
@@ -61,12 +66,16 @@ async function fetchWithGuards(url, params, cacheKey) {
await limiter.waitForToken(); await limiter.waitForToken();
try { try {
const data = await breaker.call(async () => { const data = await breaker.call(async () => {
const res = await axios.get(url, { const res = await gateway.fetch(
params, 'parlayapi',
headers: { 'X-Api-Key': process.env.PARLAYAPI_KEY }, () => axios.get(url, {
timeout: HTTP_TIMEOUT_MS, params,
validateStatus: (s) => (s >= 200 && s < 300) || s === 429, headers: { 'X-Api-Key': process.env.PARLAYAPI_KEY },
}); timeout: HTTP_TIMEOUT_MS,
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
}),
{ capability: 'historical_props', sport: params && params.sport },
);
if (res.status === 429) { if (res.status === 429) {
const err = new Error('parlayapi rate limited'); const err = new Error('parlayapi rate limited');
err.code = 'PARLAYAPI_429'; err.code = 'PARLAYAPI_429';
+15 -7
View File
@@ -15,6 +15,10 @@
const axios = require('axios'); const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis'); const { cacheGet, cacheSet } = require('../../utils/redis');
// Session 21 — gateway wrap; see tank01NbaAdapter for the rationale.
// Same provider ID since both NBA and MLB endpoints share the
// RAPID_API_KEY quota (1,000 req/month combined).
const gateway = require('../providerGateway');
const HTTP_TIMEOUT_MS = 8_000; const HTTP_TIMEOUT_MS = 8_000;
const DEFAULT_HOST = 'tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com'; const DEFAULT_HOST = 'tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com';
@@ -41,13 +45,17 @@ async function fetchWithCache(path, cacheKey, ttl) {
try { try {
const host = getHost(); const host = getHost();
const res = await axios.get(`https://${host}${path}`, { const res = await gateway.fetch(
headers: { 'tank01',
'x-rapidapi-key': process.env.RAPID_API_KEY, () => axios.get(`https://${host}${path}`, {
'x-rapidapi-host': host, headers: {
}, 'x-rapidapi-key': process.env.RAPID_API_KEY,
timeout: HTTP_TIMEOUT_MS, 'x-rapidapi-host': host,
}); },
timeout: HTTP_TIMEOUT_MS,
}),
{ capability: 'box_scores', sport: 'mlb' },
);
const body = res.data; const body = res.data;
if (body && typeof body === 'object') { if (body && typeof body === 'object') {
await cacheSet(cacheKey, body, ttl); await cacheSet(cacheKey, body, ttl);
+16 -7
View File
@@ -23,6 +23,11 @@
const axios = require('axios'); const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis'); const { cacheGet, cacheSet } = require('../../utils/redis');
// Session 21 — every Tank01 hit flows through the provider gateway
// so the monthly counter advances and the WARN/BLOCK thresholds
// catch us before RapidAPI 429s. The cache layer below still
// short-circuits before the gateway in the common path.
const gateway = require('../providerGateway');
const HTTP_TIMEOUT_MS = 8_000; const HTTP_TIMEOUT_MS = 8_000;
const DEFAULT_HOST = 'tank01-fantasy-stats.p.rapidapi.com'; const DEFAULT_HOST = 'tank01-fantasy-stats.p.rapidapi.com';
@@ -50,13 +55,17 @@ async function fetchWithCache(path, cacheKey, ttl) {
try { try {
const host = getHost(); const host = getHost();
const res = await axios.get(`https://${host}${path}`, { const res = await gateway.fetch(
headers: { 'tank01',
'x-rapidapi-key': process.env.RAPID_API_KEY, () => axios.get(`https://${host}${path}`, {
'x-rapidapi-host': host, headers: {
}, 'x-rapidapi-key': process.env.RAPID_API_KEY,
timeout: HTTP_TIMEOUT_MS, 'x-rapidapi-host': host,
}); },
timeout: HTTP_TIMEOUT_MS,
}),
{ capability: 'box_scores', sport: 'nba' },
);
const body = res.data; const body = res.data;
if (body && typeof body === 'object') { if (body && typeof body === 'object') {
await cacheSet(cacheKey, body, ttl); await cacheSet(cacheKey, body, ttl);
+63 -3
View File
@@ -26,6 +26,16 @@
const { cacheGet, cacheSet, isDegraded } = require('../utils/redis'); const { cacheGet, cacheSet, isDegraded } = require('../utils/redis');
const { getProvider, THRESHOLDS, getConfiguredProviders } = require('../config/providers'); const { getProvider, THRESHOLDS, getConfiguredProviders } = require('../config/providers');
// Session 21 — ntfy push for the WARN/BLOCK transitions. Lazy-
// imported inside `sendQuotaAlert` so the tracker stays loadable
// in environments without axios (it's a dev-time dependency
// already, but lazy-require keeps the symmetry with the gateway).
let _axios = null;
function getAxios() {
if (_axios) return _axios;
try { _axios = require('axios'); } catch { _axios = null; }
return _axios;
}
function pad(n) { function pad(n) {
return String(n).padStart(2, '0'); return String(n).padStart(2, '0');
@@ -74,6 +84,45 @@ function buildWarnKey(providerId, now = new Date()) {
return `quota_warned:${providerId}:${getPeriodKey(providerId, now)}`; return `quota_warned:${providerId}:${getPeriodKey(providerId, now)}`;
} }
/**
* Best-effort push notification when a provider crosses WARN or
* BLOCK. Two priorities:
* - WARN (80%) → priority 4 ("high")
* - BLOCK (95%) → priority 5 ("urgent")
*
* Failure is silent — ntfy down must NEVER block the call path.
* Disabled when `NTFY_URL` is unset (development default).
*/
async function sendQuotaAlert(providerCfg, pct, used, limit) {
const url = process.env.NTFY_URL;
if (!url) return false;
const axios = getAxios();
if (!axios) return false;
const blocked = pct >= THRESHOLDS.BLOCK_PCT;
const pctRounded = Math.round(pct * 100);
const title = blocked
? `VYNDR Quota BLOCKED: ${providerCfg.name}`
: `VYNDR Quota Warning: ${providerCfg.name}`;
const body = blocked
? `${providerCfg.name} at ${pctRounded}% quota (${used}/${limit}). Calls now BLOCKED. Fallback chain active where configured. Check /admin.`
: `${providerCfg.name} at ${pctRounded}% quota (${used}/${limit}). Approaching block threshold (95%). Check /admin.`;
try {
await axios.post(url, body, {
headers: {
Title: title,
Priority: blocked ? '5' : '4',
Tags: blocked ? 'no_entry,chart_decreasing' : 'warning,chart_decreasing',
},
timeout: 5000,
});
return true;
} catch (err) {
// ntfy failure is a signal-degradation, not a path-failure.
console.warn('[quotaTracker] ntfy alert failed:', err && err.message ? err.message : err);
return false;
}
}
/** /**
* Read the counter without mutating it. Returns the structured * Read the counter without mutating it. Returns the structured
* status the admin dashboard renders + the gateway consults. * status the admin dashboard renders + the gateway consults.
@@ -140,13 +189,24 @@ async function recordCall(providerId) {
await cacheSet(key, payload, getQuotaTTL(providerId)); await cacheSet(key, payload, getQuotaTTL(providerId));
if (pct >= THRESHOLDS.WARN_PCT) { if (pct >= THRESHOLDS.WARN_PCT) {
const warnKey = buildWarnKey(providerId); // Session 21 — separate dedupe keys for WARN and BLOCK so each
const already = await cacheGet(warnKey); // threshold can fire once per period. Without the second key,
// a provider that hops 75% → 96% in one call would only send
// ONE alert (the WARN); the operator wouldn't get the BLOCK
// notice that's actually the actionable one.
const blocked = pct >= THRESHOLDS.BLOCK_PCT;
const dedupeKey = blocked ? `${buildWarnKey(providerId)}:block` : buildWarnKey(providerId);
const already = await cacheGet(dedupeKey);
if (!already) { if (!already) {
console.warn( console.warn(
`[quotaTracker] ${cfg.name} at ${(pct * 100).toFixed(0)}% quota (${nextUsed}/${limit}) for ${getPeriodKey(providerId)}`, `[quotaTracker] ${cfg.name} at ${(pct * 100).toFixed(0)}% quota (${nextUsed}/${limit}) for ${getPeriodKey(providerId)}`,
); );
await cacheSet(warnKey, '1', getQuotaTTL(providerId)); await cacheSet(dedupeKey, '1', getQuotaTTL(providerId));
// Fire ntfy off the hot path — we don't await it. Errors are
// already caught inside sendQuotaAlert, but skipping the await
// also means a slow ntfy server can't add latency to the
// adapter's HTTP call.
sendQuotaAlert(cfg, pct, nextUsed, limit).catch(() => {});
} }
} }
@@ -0,0 +1,121 @@
// Integration: gateway wiring across adapters (Session 21).
//
// Verifies that the per-adapter `fetchWithCache` gateway wrap
// actually drives the quotaTracker counter forward — i.e. each
// successful adapter HTTP call increments the right provider's
// quota, and the gateway short-circuits when the counter passes
// the BLOCK threshold.
//
// We test through the Tank01 NBA adapter because it has the
// simplest fetchWithCache (no token-bucket / circuit-breaker), so
// the gateway behavior dominates. The same pattern applies to the
// other adapters by symmetry; their per-adapter unit suites cover
// their internal logic.
// In-memory store backing the redis mock. Shared across the
// tracker + adapter so the counter the tracker increments is the
// one the gateway reads back on the next call. `mock`-prefixed so
// jest's `jest.mock` hoisting accepts the reference inside the
// factory.
const mockStore = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: jest.fn(async (key) => (mockStore.has(key) ? mockStore.get(key) : null)),
cacheSet: jest.fn(async (key, value) => { mockStore.set(key, value); return true; }),
cacheDel: jest.fn(async (key) => { mockStore.delete(key); return true; }),
isDegraded: jest.fn(() => false),
getRedisClient: () => ({}),
}));
const mockAxiosGet = jest.fn();
jest.mock('axios', () => ({
get: (...args) => mockAxiosGet(...args),
post: jest.fn(async () => ({ status: 200 })),
}));
beforeEach(() => {
mockStore.clear();
mockAxiosGet.mockReset();
delete process.env.NTFY_URL;
process.env.RAPID_API_KEY = 'test-rapid';
});
describe('Tank01 → gateway → quotaTracker (Session 21)', () => {
test('a successful adapter call increments the tank01 counter', async () => {
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
const tracker = require('../../src/services/quotaTracker');
// The Tank01 NBA box-score endpoint returns `{body: {playerStats:
// {...}}}`. We don't assert the projection here — that's the
// adapter's unit test — only the side effect on the tracker.
mockAxiosGet.mockResolvedValueOnce({ data: { body: { playerStats: {} } } });
const before = await tracker.getQuotaStatus('tank01');
expect(before.used).toBe(0);
await adapter.getNBABoxScore('g1');
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
const after = await tracker.getQuotaStatus('tank01');
expect(after.used).toBe(1);
expect(after.allowed).toBe(true);
});
test('counter does NOT advance when the cache hits before HTTP', async () => {
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
const tracker = require('../../src/services/quotaTracker');
// First call → HTTP, counter to 1.
mockAxiosGet.mockResolvedValueOnce({ data: { body: { playerStats: {} } } });
await adapter.getNBABoxScore('g1');
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
let status = await tracker.getQuotaStatus('tank01');
expect(status.used).toBe(1);
// Second call → cache hit, no HTTP, counter stays at 1.
await adapter.getNBABoxScore('g1');
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
status = await tracker.getQuotaStatus('tank01');
expect(status.used).toBe(1);
});
test('gateway blocks the call at 95% — adapter returns null (no stale)', async () => {
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
const tracker = require('../../src/services/quotaTracker');
// Seed the counter at the BLOCK threshold via the truth-source
// syncFromHeaders so we don't have to fire 950 calls.
await tracker.syncFromHeaders('tank01', {
'x-quota-used': '950',
'x-quota-limit': '1000',
});
const status = await tracker.getQuotaStatus('tank01');
expect(status.allowed).toBe(false);
// The adapter wraps the axios call in gateway.fetch — the
// gateway short-circuits before invoking axios.
const result = await adapter.getNBABoxScore('blocked-game');
expect(mockAxiosGet).not.toHaveBeenCalled();
// No stale cache → adapter degrades to null.
expect(result).toBeNull();
});
test('quota-exhausted call rolls back the optimistic increment', async () => {
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
const tracker = require('../../src/services/quotaTracker');
// Seed at 50 so we can observe rollback unambiguously.
await tracker.syncFromHeaders('tank01', {
'x-quota-used': '50',
'x-quota-limit': '1000',
});
// Network throws after the counter increments → gateway rolls
// it back so a failed call doesn't consume quota.
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 502'));
const result = await adapter.getNBABoxScore('exploding-game');
expect(result).toBeNull(); // adapter's catch returns null
const after = await tracker.getQuotaStatus('tank01');
expect(after.used).toBe(50); // rollback remockStored
});
});
+5
View File
@@ -9,6 +9,11 @@ jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => mockCache.current.get(k) ?? null, cacheGet: async (k) => mockCache.current.get(k) ?? null,
cacheSet: async (k, v) => { mockCache.current.set(k, v); return true; }, cacheSet: async (k, v) => { mockCache.current.set(k, v); return true; },
cacheDel: async (k) => { mockCache.current.delete(k); return true; }, cacheDel: async (k) => { mockCache.current.delete(k); return true; },
// Session 21 — gateway pulls isDegraded to decide whether to track
// the call. Returning true here puts the gateway in degraded-mode
// fail-open so the adapter's existing cache-and-axios assertions
// stay accurate (no extra Redis writes for the quota counter).
isDegraded: () => true,
})); }));
const adapter = require('../../src/services/adapters/parlayApiAdapter'); const adapter = require('../../src/services/adapters/parlayApiAdapter');
+120
View File
@@ -7,6 +7,12 @@
// `allowed` from true to false. Redis is mocked so the tests don't // `allowed` from true to false. Redis is mocked so the tests don't
// require a live server. // require a live server.
// Session 21 — mock axios so the ntfy POST inside recordCall
// doesn't try to hit the network. The mock is scoped per-test via
// resetAllMocks below.
jest.mock('axios', () => ({ post: jest.fn(async () => ({ status: 200 })) }));
const axios = require('axios');
jest.mock('../../src/utils/redis', () => { jest.mock('../../src/utils/redis', () => {
const store = new Map(); const store = new Map();
return { return {
@@ -39,12 +45,20 @@ beforeEach(() => {
redis.cacheSet.mockClear(); redis.cacheSet.mockClear();
redis.cacheDel.mockClear(); redis.cacheDel.mockClear();
redis.isDegraded.mockReturnValue(false); redis.isDegraded.mockReturnValue(false);
axios.post.mockClear();
delete process.env.NTFY_URL;
process.env.ODDS_API_KEY = 'test-odds-key'; process.env.ODDS_API_KEY = 'test-odds-key';
process.env.RAPID_API_KEY = 'test-tank01-key'; process.env.RAPID_API_KEY = 'test-tank01-key';
process.env.API_FOOTBALL_KEY = 'test-apifoot-key'; process.env.API_FOOTBALL_KEY = 'test-apifoot-key';
process.env.FOOTBALL_DATA_API_KEY = 'test-fd-key'; process.env.FOOTBALL_DATA_API_KEY = 'test-fd-key';
}); });
// Helper for the ntfy block — awaits a microtask flush so the
// fire-and-forget `sendQuotaAlert(...).catch(...)` inside
// recordCall has resolved before assertions run. Without this the
// axios.post mock can be observed pre-call from inside the test.
const flushAsync = () => new Promise((r) => setImmediate(r));
describe('quotaTracker.getPeriodKey', () => { describe('quotaTracker.getPeriodKey', () => {
test('monthly produces YYYY-MM', () => { test('monthly produces YYYY-MM', () => {
const key = tracker.getPeriodKey('odds-api', new Date(Date.UTC(2026, 5, 12))); const key = tracker.getPeriodKey('odds-api', new Date(Date.UTC(2026, 5, 12)));
@@ -207,6 +221,112 @@ describe('quotaTracker degraded-mode fail-open', () => {
}); });
}); });
describe('quotaTracker ntfy alerts (Session 21)', () => {
test('does NOT post to ntfy when NTFY_URL is unset', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
await tracker.recordCall('odds-api'); // 400/500 → 80%
await flushAsync();
expect(axios.post).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
test('posts WARN to ntfy at 80% with priority 4', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
await tracker.recordCall('odds-api'); // 400/500 → 80%
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
const [url, body, opts] = axios.post.mock.calls[0];
expect(url).toBe('https://alerts.example.com/vyndr');
expect(body).toMatch(/Odds API/);
expect(body).toMatch(/80%/);
expect(opts.headers.Priority).toBe('4');
expect(opts.headers.Title).toMatch(/Warning/);
warnSpy.mockRestore();
});
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(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '474',
'x-requests-remaining': '26',
});
await tracker.recordCall('odds-api'); // 475/500 → 95%
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
const [, body, opts] = axios.post.mock.calls[0];
expect(body).toMatch(/BLOCKED|95%/);
expect(opts.headers.Priority).toBe('5');
expect(opts.headers.Title).toMatch(/BLOCKED/);
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(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
await tracker.recordCall('odds-api');
await flushAsync();
await tracker.recordCall('odds-api');
await flushAsync();
await tracker.recordCall('odds-api');
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
test('WARN→BLOCK transition fires BOTH alerts (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).
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '395',
'x-requests-remaining': '105',
});
await tracker.recordCall('odds-api'); // 396 → 79.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
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
expect(axios.post.mock.calls[0][2].headers.Priority).toBe('5');
warnSpy.mockRestore();
});
test('axios.post failure does NOT break recordCall', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
axios.post.mockRejectedValueOnce(new Error('ntfy down'));
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
const result = await tracker.recordCall('odds-api');
await flushAsync();
// recordCall returns the normal status even when ntfy throws.
expect(result.allowed).toBe(true);
expect(result.used).toBe(400);
warnSpy.mockRestore();
});
});
describe('quotaTracker.getAllQuotaStatuses', () => { describe('quotaTracker.getAllQuotaStatuses', () => {
test('returns one entry per configured provider', async () => { test('returns one entry per configured provider', async () => {
const statuses = await tracker.getAllQuotaStatuses(); const statuses = await tracker.getAllQuotaStatuses();
+1 -1
View File
File diff suppressed because one or more lines are too long