Session 21: All adapters through gateway, ntfy alerts, provider registry correction (1486 tests)
This commit is contained in:
+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}`, {
|
||||
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') {
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user