Documentation
Graceful Degradation for Rate Outages
Use a circuit breaker and a dated last-known snapshot when the API is slow or failing, with a maximum stale age per business use.
An outage should not turn every request into a thirty second wait, and it should not turn a checkout into a guess. Decide in advance which operations may use a last-known snapshot, how old that snapshot may be, and what happens when it is older. That maximum age is a business decision, not a caching default.
The API returns central-bank reference rates polled hourly from daily fixings. A short outage may mean you are missing only the value you already hold, but it can also span a new fixing. Treat 5xx, timeouts, and connection failures as service failures that count toward a breaker. Treat 429 as a pacing signal, not an outage: honor Retry-After and slow down, and let your retry layer own it rather than tripping the breaker. Stale fallback is for service failures, not budget pressure.
type Rates = { date: string; rates: Record<string, number> };
type Snapshot = { body: Rates; fetchedAt: number };
type Use = "checkout" | "invoiceDraft" | "display";
declare function fetchRates(key: string): Promise<Rates>;
const MAX_STALE_MS: Record<Use, number> = {
checkout: 5 * 60_000, // fail closed past this
invoiceDraft: 60 * 60_000,
display: 24 * 60 * 60_000,
};
let snapshot: Snapshot | undefined;
let failures = 0;
let openUntil = 0;
function isTransient(err: unknown) {
const status = (err as { status?: number }).status;
return status === undefined || status >= 500;
}
export async function getRates(use: Use, key: string) {
if (Date.now() >= openUntil) {
try {
const body = await fetchRates(key); // throws on network or non-2xx
failures = 0;
snapshot = { body, fetchedAt: Date.now() };
return { body, stale: false, ageMs: 0 };
} catch (err) {
if (!isTransient(err)) throw err; // 400, 401, 402, 403
failures += 1;
if (failures >= 3) openUntil = Date.now() + 30_000;
}
}
if (!snapshot) throw new Error("no snapshot available");
const ageMs = Date.now() - snapshot.fetchedAt;
if (ageMs > MAX_STALE_MS[use]) throw new Error(`snapshot too old: ${ageMs}ms`);
return { body: snapshot.body, stale: true, ageMs };
}
The fallback is only defensible if it is visible. Log stale, ageMs, and body.date on every degraded response, and surface a banner or a review flag. Pricing an order from an ancient rate is worse than rejecting it: a stale value can undercharge, overcharge, or produce an invoice that fails reconciliation months later. For a hypothetical 250,000 EUR order, a 0.3 percent stale move is 750 EUR, which is a finance incident rather than a display glitch. Checkout should therefore fail closed at a tight age and route to manual review; a dashboard can tolerate a day-old snapshot because nobody books revenue from it.