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
+21 -8
View File
@@ -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 ===
+17 -4
View File
@@ -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}`, {
headers: { 'x-apisports-key': process.env.API_FOOTBALL_KEY },
timeout: HTTP_TIMEOUT_MS,
});
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') {
+16 -4
View File
@@ -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}`, {
headers: { 'X-Auth-Token': process.env.FOOTBALL_DATA_API_KEY },
timeout: HTTP_TIMEOUT_MS,
});
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 -5
View File
@@ -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`, {
params: { bookmaker: 'pinnacle', market: 'player_props' },
headers: { 'X-Api-Key': process.env.ODDSPAPI_KEY },
timeout: HTTP_TIMEOUT_MS,
});
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(
+15 -6
View File
@@ -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, {
params,
headers: { 'X-Api-Key': process.env.PARLAYAPI_KEY },
timeout: HTTP_TIMEOUT_MS,
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
});
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 -7
View File
@@ -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}`, {
headers: {
'x-rapidapi-key': process.env.RAPID_API_KEY,
'x-rapidapi-host': host,
},
timeout: HTTP_TIMEOUT_MS,
});
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);
+16 -7
View File
@@ -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}`, {
headers: {
'x-rapidapi-key': process.env.RAPID_API_KEY,
'x-rapidapi-host': host,
},
timeout: HTTP_TIMEOUT_MS,
});
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);
+63 -3
View File
@@ -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(() => {});
}
}