Session 20: Provider intelligence — quota tracker, gateway with fallback cascade, admin quota dashboard (1476 tests)

This commit is contained in:
Kev
2026-06-12 00:54:39 -04:00
parent 56392ec8f4
commit 9b10bb4138
17 changed files with 1422 additions and 15 deletions
+49 -13
View File
@@ -1,6 +1,12 @@
const axios = require('axios');
const { getRedisClient } = require('../utils/redis');
const { normalizeProps, extractSpreads, MARKET_MAP } = require('../utils/oddsNormalizer');
// Session 20 — every odds-api hit now flows through the gateway so
// the quota counter advances and the fallback chain (oddspapi,
// parlayapi) can take over when we approach the monthly cap. The
// gateway is intentionally light-weight on the hot path — it adds
// one Redis GET + SET per call (degrades open if Redis is down).
const gateway = require('./providerGateway');
const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
const CACHE_TTL = 900; // 15 minutes in seconds
@@ -137,6 +143,19 @@ async function getQuotaRemaining(redis) {
async function updateQuota(redis, headers) {
const remaining = headers['x-requests-remaining'];
const used = headers['x-requests-used'];
// Session 20 — sync the per-provider tracker from the same
// headers. The gateway's syncHeadersFrom already does this on
// each call; doing it here too is belt-and-suspenders for any
// call path that bypassed the gateway. Lazy-required so the
// tests that don't mock providerGateway don't crash on load.
try {
const quotaTracker = require('./quotaTracker');
await quotaTracker.syncFromHeaders('odds-api', headers);
} catch (e) {
// Tracker failure must never break odds delivery — it's a
// signal, not a dependency.
console.warn('[oddsService] quotaTracker sync failed:', e.message);
}
if (remaining != null) {
const key = getQuotaKey();
await redis.hset(key, 'remaining', String(remaining), 'used', String(used || 0), 'last_checked', new Date().toISOString());
@@ -150,10 +169,19 @@ async function updateQuota(redis, headers) {
async function fetchEventsFromApi(sportKey, apiKey) {
const url = `${ODDS_API_BASE}/${sportKey}/events`;
const response = await axios.get(url, {
params: { apiKey },
timeout: 10000,
});
const response = await gateway.fetch(
'odds-api',
() => axios.get(url, { params: { apiKey }, timeout: 10000 }),
{
capability: 'odds',
// Best-effort sport tag for the fallback chain. The events
// endpoint isn't sport-scoped on the fallback providers, but
// passing it through lets the registry filter to relevant
// candidates.
sport: sportKey.replace(/^.*?_/, ''),
syncHeadersFrom: (r) => r && r.headers,
},
);
return { data: response.data, headers: response.headers };
}
@@ -163,16 +191,24 @@ async function fetchEventsFromApi(sportKey, apiKey) {
// market set, which is what every legacy caller assumed.
async function fetchEventOddsFromApi(sportKey, eventId, apiKey, sport) {
const url = `${ODDS_API_BASE}/${sportKey}/events/${eventId}/odds`;
const response = await axios.get(url, {
params: {
apiKey,
regions: 'us',
markets: getMarketsForSport(sport),
bookmakers: BOOKMAKERS,
oddsFormat: 'american',
const response = await gateway.fetch(
'odds-api',
() => axios.get(url, {
params: {
apiKey,
regions: 'us',
markets: getMarketsForSport(sport),
bookmakers: BOOKMAKERS,
oddsFormat: 'american',
},
timeout: 10000,
}),
{
capability: 'odds',
sport,
syncHeadersFrom: (r) => r && r.headers,
},
timeout: 10000,
});
);
return { data: response.data, headers: response.headers };
}