# 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.

Canonical: https://currency-core.com/docs/guides/graceful-degradation/
Updated: 2026-11-11
Published: 2026-11-11

---
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.

```ts
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.

## Related

- [API reference](/docs/api/)
- [Authentication](/docs/authentication/)
- [Errors](/docs/errors/)
- [Rate limits](/docs/rate-limits/)
- [Guides](/docs/guides/)
- [Caching reference rates](/docs/guides/caching/)
- [Retries and backoff](/docs/guides/retries/)
- [Observability](/docs/guides/observability/)
