Session 20: Provider intelligence — quota tracker, gateway with fallback cascade, admin quota dashboard (1476 tests)
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
'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) {
|
||||
// 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,
|
||||
} = opts;
|
||||
|
||||
const attempts = [];
|
||||
const result = await tryOne(primaryId, callbackFn, syncHeadersFrom);
|
||||
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);
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user