'use strict'; /** * Provider gateway (Session 20). * * The single entry point every external-data call passes through. * Adapters call: * * const result = await gateway.fetch('odds-api', cbWithProvider, { * capability: 'odds', * sport: 'nba', * fallbackProviders: ['oddspapi'], // optional override * syncHeadersFrom: (r) => r.headers, // optional * }); * * Flow: * 1. Check primary provider's quota via quotaTracker * 2. If allowed → invoke callback, sync headers on success * 3. If blocked → walk the fallback chain (explicit or * capability-derived from the registry) * 4. If every provider is exhausted → throw QuotaExhaustedError * with a structured `attempts` log so the operator can see * what was tried * 5. Adapter-thrown errors propagate after rollback * * Callback receives the providerId actually being used so it can * pick the right base URL / API key for fallbacks. For * single-provider calls, callers can ignore the argument. */ const quotaTracker = require('./quotaTracker'); const { getFallbackChain } = require('../config/providers'); class QuotaExhaustedError extends Error { constructor(primary, sport, attempts) { super(`All providers exhausted for ${primary}/${sport || '*'}. Tried: ${attempts.map((a) => `${a.provider}=${a.reason}`).join('; ')}`); this.name = 'QuotaExhaustedError'; this.code = 'QUOTA_EXHAUSTED'; this.statusCode = 503; this.primary = primary; this.sport = sport; this.attempts = attempts; } } async function tryOne(providerId, callbackFn, syncHeadersFrom, reserve = 0) { // Reserve floor (Job 1 / quota guard) — a DISCRETIONARY call (futures, // soccer outrights) is refused while fewer than `reserve` credits remain, so // it can never drain the last credits that the ESSENTIAL path (MLB prop // backup when PropLine fails) depends on. Essential calls pass reserve=0 and // use the quota down to the normal 95% block. Checked BEFORE the optimistic // increment so we don't consume-then-refund. Degraded Redis fails open. if (reserve > 0) { const pre = await quotaTracker.getQuotaStatus(providerId); if (pre && !pre.degraded && Number.isFinite(pre.remaining) && pre.remaining <= reserve) { return { ok: false, reason: `reserve_floor(${pre.remaining}<=${reserve})`, status: pre }; } } // Optimistic increment — if the call throws we roll back below. // recordCall also evaluates the post-increment threshold; if the // very next call would put us at 95%+, we still execute THIS one // (it returned allowed:true before incrementing) and the NEXT // call will see the block. const status = await quotaTracker.recordCall(providerId); if (!status.allowed) { await quotaTracker.rollback(providerId); return { ok: false, reason: status.reason || 'blocked', status }; } try { const result = await callbackFn(providerId); // Best-effort header sync — caller signals where the headers // live on the response object. Failure is non-fatal; the // optimistic counter remains. if (typeof syncHeadersFrom === 'function') { try { const headers = syncHeadersFrom(result); if (headers) await quotaTracker.syncFromHeaders(providerId, headers); } catch (e) { console.warn(`[gateway] header sync failed for ${providerId}: ${e.message}`); } } return { ok: true, result, provider: providerId }; } catch (err) { await quotaTracker.rollback(providerId); return { ok: false, reason: err && err.message ? err.message : 'error', err }; } } /** * Invoke `callbackFn` against the primary provider, falling over * to alternatives in the fallback chain if quota is exhausted. * * IMPORTANT: this only retries fallbacks on QUOTA failures, not on * generic upstream errors. A network blip on the primary doesn't * silently shift the entire platform to the fallback (that masks * outages); it surfaces as the adapter's normal error path. */ async function fetch(primaryId, callbackFn, opts = {}) { const { capability, sport, fallbackProviders, syncHeadersFrom, reserve = 0, } = opts; const attempts = []; const result = await tryOne(primaryId, callbackFn, syncHeadersFrom, reserve); if (result.ok) return result.result; // Generic adapter error on the primary — propagate, don't shift. if (result.err) { attempts.push({ provider: primaryId, reason: result.reason }); throw result.err; } attempts.push({ provider: primaryId, reason: result.reason }); // Build the fallback chain. Caller can override; otherwise derive // from the capability/sport pair in the registry. const chain = Array.isArray(fallbackProviders) && fallbackProviders.length ? fallbackProviders : capability ? getFallbackChain(capability, sport, primaryId) : []; for (const fallbackId of chain) { const fb = await tryOne(fallbackId, callbackFn, syncHeadersFrom, reserve); if (fb.ok) { console.log(`[gateway] primary=${primaryId} blocked; succeeded via fallback=${fallbackId}`); return fb.result; } attempts.push({ provider: fallbackId, reason: fb.reason }); // Generic error on a fallback → record and continue to the next. // We don't propagate fallback errors because the user only sees // one final response, and the original primary was already // unavailable when we entered this loop. } throw new QuotaExhaustedError(primaryId, sport, attempts); } module.exports = { fetch, QuotaExhaustedError, };