Session 21: All adapters through gateway, ntfy alerts, provider registry correction (1486 tests)
This commit is contained in:
+182
-1
@@ -4,7 +4,188 @@
|
||||
2026-06-12
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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: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-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
@@ -38,25 +38,38 @@ const PROVIDERS = {
|
||||
capabilities: ['odds', 'props', 'lines', 'spreads'],
|
||||
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': {
|
||||
name: 'ODDSPAPI',
|
||||
name: 'ODDSPAPI (Pinnacle close)',
|
||||
envKey: 'ODDSPAPI_KEY',
|
||||
quotaType: 'monthly',
|
||||
quotaLimit: 1000,
|
||||
resetDay: 1,
|
||||
sports: ['nba', 'wnba', 'mlb', 'nfl'],
|
||||
capabilities: ['odds', 'props'],
|
||||
priority: 2,
|
||||
sports: ['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb'],
|
||||
capabilities: ['closing_lines'],
|
||||
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': {
|
||||
name: 'ParlayAPI',
|
||||
name: 'ParlayAPI (historical)',
|
||||
envKey: 'PARLAYAPI_KEY',
|
||||
quotaType: 'monthly',
|
||||
quotaLimit: 1000,
|
||||
resetDay: 1,
|
||||
sports: ['nba', 'wnba', 'mlb', 'nfl'],
|
||||
capabilities: ['odds', 'parlays', 'correlations'],
|
||||
priority: 3,
|
||||
sports: ['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb'],
|
||||
capabilities: ['historical_props', 'historical_lines'],
|
||||
priority: 1,
|
||||
},
|
||||
|
||||
// === STATS / BOX SCORES ===
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
|
||||
const axios = require('axios');
|
||||
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 HTTP_TIMEOUT_MS = 8_000;
|
||||
@@ -93,10 +102,14 @@ async function fetchWithCache(path, cacheKey, ttl) {
|
||||
|
||||
// 4. Network.
|
||||
try {
|
||||
const res = await axios.get(`${BASE_URL}${path}`, {
|
||||
const res = await gateway.fetch(
|
||||
'api-football',
|
||||
() => axios.get(`${BASE_URL}${path}`, {
|
||||
headers: { 'x-apisports-key': process.env.API_FOOTBALL_KEY },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
}),
|
||||
{ capability: 'lineups', sport: 'soccer' },
|
||||
);
|
||||
await bumpDailyCount();
|
||||
const body = res.data;
|
||||
if (body && typeof body === 'object') {
|
||||
|
||||
@@ -34,6 +34,14 @@
|
||||
|
||||
const axios = require('axios');
|
||||
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 HTTP_TIMEOUT_MS = 8_000;
|
||||
@@ -103,10 +111,14 @@ async function fetchWithCache(path, cacheKey, ttl) {
|
||||
|
||||
// 4. Hit the network.
|
||||
try {
|
||||
const res = await axios.get(`${BASE_URL}${path}`, {
|
||||
const res = await gateway.fetch(
|
||||
'football-data',
|
||||
() => 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;
|
||||
if (body && typeof body === 'object') {
|
||||
// Write to BOTH the live and stale keys. Stale key has a much
|
||||
|
||||
@@ -15,6 +15,12 @@ const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../utils/rateLimiter');
|
||||
const { devig } = require('../../utils/odds');
|
||||
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 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();
|
||||
try {
|
||||
return await breaker.call(async () => {
|
||||
const res = await axios.get(`${BASE_URL}/sports/${sportKey(sport)}/events/${gameId}/odds`, {
|
||||
const res = await gateway.fetch(
|
||||
'oddspapi',
|
||||
() => axios.get(`${BASE_URL}/sports/${sportKey(sport)}/events/${gameId}/odds`, {
|
||||
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 || [];
|
||||
return Array.isArray(props)
|
||||
? props.find(
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
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 HTTP_TIMEOUT_MS = 10_000;
|
||||
@@ -61,12 +66,16 @@ async function fetchWithGuards(url, params, cacheKey) {
|
||||
await limiter.waitForToken();
|
||||
try {
|
||||
const data = await breaker.call(async () => {
|
||||
const res = await axios.get(url, {
|
||||
const res = await gateway.fetch(
|
||||
'parlayapi',
|
||||
() => axios.get(url, {
|
||||
params,
|
||||
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) {
|
||||
const err = new Error('parlayapi rate limited');
|
||||
err.code = 'PARLAYAPI_429';
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
|
||||
const axios = require('axios');
|
||||
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 DEFAULT_HOST = 'tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com';
|
||||
@@ -41,13 +45,17 @@ async function fetchWithCache(path, cacheKey, ttl) {
|
||||
|
||||
try {
|
||||
const host = getHost();
|
||||
const res = await axios.get(`https://${host}${path}`, {
|
||||
const res = await gateway.fetch(
|
||||
'tank01',
|
||||
() => axios.get(`https://${host}${path}`, {
|
||||
headers: {
|
||||
'x-rapidapi-key': process.env.RAPID_API_KEY,
|
||||
'x-rapidapi-host': host,
|
||||
},
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
}),
|
||||
{ capability: 'box_scores', sport: 'mlb' },
|
||||
);
|
||||
const body = res.data;
|
||||
if (body && typeof body === 'object') {
|
||||
await cacheSet(cacheKey, body, ttl);
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
|
||||
const axios = require('axios');
|
||||
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 DEFAULT_HOST = 'tank01-fantasy-stats.p.rapidapi.com';
|
||||
@@ -50,13 +55,17 @@ async function fetchWithCache(path, cacheKey, ttl) {
|
||||
|
||||
try {
|
||||
const host = getHost();
|
||||
const res = await axios.get(`https://${host}${path}`, {
|
||||
const res = await gateway.fetch(
|
||||
'tank01',
|
||||
() => axios.get(`https://${host}${path}`, {
|
||||
headers: {
|
||||
'x-rapidapi-key': process.env.RAPID_API_KEY,
|
||||
'x-rapidapi-host': host,
|
||||
},
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
}),
|
||||
{ capability: 'box_scores', sport: 'nba' },
|
||||
);
|
||||
const body = res.data;
|
||||
if (body && typeof body === 'object') {
|
||||
await cacheSet(cacheKey, body, ttl);
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
|
||||
const { cacheGet, cacheSet, isDegraded } = require('../utils/redis');
|
||||
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) {
|
||||
return String(n).padStart(2, '0');
|
||||
@@ -74,6 +84,45 @@ function buildWarnKey(providerId, now = new Date()) {
|
||||
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
|
||||
* status the admin dashboard renders + the gateway consults.
|
||||
@@ -140,13 +189,24 @@ async function recordCall(providerId) {
|
||||
await cacheSet(key, payload, getQuotaTTL(providerId));
|
||||
|
||||
if (pct >= THRESHOLDS.WARN_PCT) {
|
||||
const warnKey = buildWarnKey(providerId);
|
||||
const already = await cacheGet(warnKey);
|
||||
// Session 21 — separate dedupe keys for WARN and BLOCK so each
|
||||
// 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) {
|
||||
console.warn(
|
||||
`[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
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,11 @@ jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => mockCache.current.get(k) ?? null,
|
||||
cacheSet: async (k, v) => { mockCache.current.set(k, v); 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');
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
// `allowed` from true to false. Redis is mocked so the tests don't
|
||||
// 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', () => {
|
||||
const store = new Map();
|
||||
return {
|
||||
@@ -39,12 +45,20 @@ beforeEach(() => {
|
||||
redis.cacheSet.mockClear();
|
||||
redis.cacheDel.mockClear();
|
||||
redis.isDegraded.mockReturnValue(false);
|
||||
axios.post.mockClear();
|
||||
delete process.env.NTFY_URL;
|
||||
process.env.ODDS_API_KEY = 'test-odds-key';
|
||||
process.env.RAPID_API_KEY = 'test-tank01-key';
|
||||
process.env.API_FOOTBALL_KEY = 'test-apifoot-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', () => {
|
||||
test('monthly produces YYYY-MM', () => {
|
||||
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', () => {
|
||||
test('returns one entry per configured provider', async () => {
|
||||
const statuses = await tracker.getAllQuotaStatuses();
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user