---
title: "Exchange Rates API in Production: Fallbacks and Error Handling"
description: "Production patterns for an exchange rates API: timeouts, retries with jitter, stale-while-revalidate, a fallback provider, a circuit breaker and sanity bounds."
canonical: https://allratestoday.com/blog/exchange-rate-api-fallback-error-handling/
source: AllRatesToday (https://allratestoday.com/) — Markdown mirror of the page above; index of all pages at https://allratestoday.com/llms.txt
generated: 2026-11-11T15:07:35.021Z
---

An exchange rates API is a dependency your app calls on every price it shows. When it is slow, down or wrong, the problem lands on your checkout page. Most integrations handle the happy path and nothing else. This tutorial builds the production layer around an exchange rates API in JavaScript: timeouts, retries with backoff and jitter, a stale-while-revalidate cache, a staleness threshold, a fallback provider, a circuit breaker, monitoring of the `time` field, and sanity bounds on each new rate.

The primary provider in the examples is AllRatesToday, and the fallback is Frankfurter. The patterns work with any pair of providers.

## Exchange rates API failure modes

Know what you are defending against before writing code.

| Failure | Looks like | Right response |
| --- | --- | --- |
| Slow response | Request hangs | Timeout, then serve cache |
| Network error, 5xx | `fetch` throws or `res.status >= 500` | Retry with backoff, then fallback |
| 401, 403 | Bad or revoked key | Do not retry. Alert. Serve cache |
| 429 | Over plan limit | Do not retry soon. Serve cache. Alert |
| Stale data | 200 OK, old `time` | Treat as degraded |
| Wrong data | 200 OK, rate far from last value | Reject, keep last good |

The last two are the dangerous ones, because nothing throws.

## Step 1: timeouts

`fetch` waits as long as the connection allows. Always pass a signal.

```
async function fetchJson(url, { headers = {}, timeoutMs = 3000 } = {}) {
  const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
  if (!res.ok) {
    const err = new Error(`HTTP ${res.status}`);
    err.status = res.status;
    err.headers = res.headers;
    throw err;
  }
  return res.json();
}
```

Three seconds is generous for a rate lookup. If the provider usually answers in 100 ms, a request still open after three seconds is not coming back soon.

## Step 2: retries with backoff and jitter

Retry only what can succeed on a second try: network errors, timeouts and 5xx. Add jitter so many instances do not retry at the same moment.

```
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function isRetryable(err) {
  if (err.status === undefined) return true; // network error or timeout
  return err.status >= 500;
}

async function withRetry(fn, { attempts = 3, baseMs = 200, maxMs = 2000 } = {}) {
  let lastErr;
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      if (!isRetryable(err) || i === attempts - 1) break;
      const cap = Math.min(maxMs, baseMs * 2 ** i);
      await sleep(Math.random() * cap); // full jitter
    }
  }
  throw lastErr;
}
```

401 and 403 fail the same way every time. 429 is different again. With AllRatesToday it means the monthly request limit is reached, and the `X-Monthly-Limit`, `X-Monthly-Used` and `X-Monthly-Resets` headers come with it. Retrying in a few hundred milliseconds just adds load. Respect the quota: serve the cache and alert. Rejected 401, 403 and 429 requests are not charged.

## Step 3: sanity bounds

A wrong rate that passes through is worse than an error. Compare each new value with the last good one.

```
const MAX_MOVE = 0.10; // 10% between refreshes

function checkRate(next, prev) {
  if (!Number.isFinite(next.rate) || next.rate <= 0) return "not a positive number";
  if (!prev) return null;
  const move = Math.abs(next.rate / prev.rate - 1);
  if (move > MAX_MOVE) return `moved ${(move * 100).toFixed(1)}% since last value`;
  return null;
}
```

Ten percent between one-minute refreshes is far beyond normal for major pairs. Pick the bound per currency: a pegged currency barely moves, while a currency in crisis can move several percent in a day. When a check fails, keep the last good value and log both numbers.

## Step 4: primary and fallback providers

Each provider gets a small function that returns the same internal shape: `{ rate, time, provider }`.

```
async function fromAllRatesToday(base, quote) {
  const url = `https://allratestoday.com/api/v1/rates?source=${base}&target=${quote}`;
  const data = await fetchJson(url, {
    headers: { Authorization: `Bearer ${process.env.ALLRATES_API_KEY}` },
  });
  const r = data[0]; // the response is always an array
  if (!r) throw new Error("empty response");
  return { rate: r.rate, time: new Date(r.time), provider: "allratestoday" };
}

async function fromFrankfurter(base, quote) {
  const url = `https://api.frankfurter.dev/v1/latest?base=${base}&symbols=${quote}`;
  const data = await fetchJson(url);
  const rate = data.rates?.[quote];
  if (typeof rate !== "number") throw new Error(`no ${quote} in response`);
  return { rate, time: new Date(`${data.date}T00:00:00Z`), provider: "frankfurter" };
}
```

Frankfurter is a reasonable daily fallback. It is free and allows commercial use. Its data is daily, so its `time` is a date, and your staleness logic will mark it as older. That is correct. Record which provider served each rate, so that a price shown on fallback data can be explained later. For other providers' shapes, see [response formats compared](https://allratestoday.com/blog/exchange-rate-api-response-formats-compared/).

## Step 5: a circuit breaker

If the primary is down, stop calling it on every request. A breaker opens after repeated failures and lets one trial request through after a cool-down.

```
class Breaker {
  constructor({ threshold = 5, coolDownMs = 30_000 } = {}) {
    this.threshold = threshold;
    this.coolDownMs = coolDownMs;
    this.failures = 0;
    this.openedAt = 0;
  }
  canTry() {
    if (this.failures < this.threshold) return true;
    return Date.now() - this.openedAt > this.coolDownMs; // half-open trial
  }
  success() { this.failures = 0; }
  failure() {
    this.failures++;
    if (this.failures >= this.threshold) this.openedAt = Date.now();
  }
}
```

## Step 6: stale-while-revalidate with a staleness threshold

Put it together. Serve a fresh cached value directly. Serve a stale one immediately and refresh in the background. Refuse to serve a value past the hard limit.

```
const FRESH_MS = 60_000;      // rates refresh every 60 seconds
const MAX_STALE_MS = 6 * 3600_000; // hard limit for display use

const cache = new Map();      // "USD:EUR" -> { rate, time, provider, fetchedAt }
const inflight = new Map();
const breaker = new Breaker();

async function refresh(base, quote) {
  const key = `${base}:${quote}`;
  if (inflight.has(key)) return inflight.get(key);

  const p = (async () => {
    const prev = cache.get(key);
    let next;
    if (breaker.canTry()) {
      try {
        next = await withRetry(() => fromAllRatesToday(base, quote));
        breaker.success();
      } catch (err) {
        if (err.status === 401 || err.status === 403 || err.status === 429) {
          alert(`primary rejected: ${err.status}`);
        } else {
          breaker.failure();
        }
      }
    }
    if (!next) next = await withRetry(() => fromFrankfurter(base, quote));

    const problem = checkRate(next, prev);
    if (problem) {
      alert(`rejected ${key} from ${next.provider}: ${problem}`);
      if (prev) return prev;
      throw new Error(`no trusted rate for ${key}`);
    }
    const entry = { ...next, fetchedAt: Date.now() };
    cache.set(key, entry);
    return entry;
  })().finally(() => inflight.delete(key));

  inflight.set(key, p);
  return p;
}

export async function getRate(base, quote) {
  const hit = cache.get(`${base}:${quote}`);
  const sinceFetch = hit ? Date.now() - hit.fetchedAt : Infinity;

  if (sinceFetch < FRESH_MS) return hit;            // fresh
  if (sinceFetch < MAX_STALE_MS) {                  // stale-while-revalidate
    refresh(base, quote).catch((e) => alert(`refresh failed: ${e.message}`));
    return hit;
  }
  return refresh(base, quote);                      // too old or empty: wait
}

function alert(msg) {
  console.error(`[fx] ${msg}`); // wire to your monitoring
}
```

The cache decides freshness from `fetchedAt`, the last time you got a trusted value. If every provider fails, it keeps serving the last good rate until `MAX_STALE_MS`, then callers get an error instead of an old price. Data age is a separate check, covered in Step 7, because a rate fetched a second ago can still be old if the provider stopped updating. The `inflight` map stops many concurrent requests for the same pair from each calling the API. In production, fetch all the targets you need in one call, such as `target=EUR,GBP,JPY,CAD`. That is one request, and the cache warms for every pair at once. More on cache layout in [how to cache exchange rates](https://allratestoday.com/blog/how-to-cache-exchange-rates-avoid-rate-limits/).

## Step 7: monitor the time field

The `time` field on each AllRatesToday pair is a direct health signal. Rates refresh every 60 seconds, so in normal operation the age of the newest rate stays around a minute. Track three numbers:

- **Rate age.** `now - time` for a reference pair such as USD/EUR. Alert if it stays above a few minutes.
- **Fallback share.** The fraction of rates served by the fallback provider. Anything above zero for long means the primary needs attention.
- **Rejections.** The count of sanity-bound rejections. Zero is normal.

A small scheduled job that calls `getRate("USD", "EUR")` and records these numbers is enough for most apps.

## Different limits for different uses

One threshold does not fit every screen. A blog widget can show a six-hour-old rate with a timestamp. A checkout should not. A sensible split:

- **Display and estimates.** Serve stale up to several hours, show the rate time.
- **Checkout.** Tighter limit. Past it, hide the local price and charge in the base currency.
- **Accounting and tax.** Do not use a live feed at all. Use the official table the rule names, such as the tables on the [central bank rates API](https://allratestoday.com/central-bank-rates-api/).

If you are picking a primary provider, the [free currency API comparison](https://allratestoday.com/blog/best-free-currency-exchange-api-2026/) covers update frequency and limits, and [how often exchange rates are updated](https://allratestoday.com/blog/how-often-are-exchange-rates-updated/) explains what a given refresh rate means in practice.

## FAQ

### Should I retry a 429 from an exchange rate API?

Not in a tight loop. A 429 means you are over your plan's limit. With AllRatesToday the limit is monthly, so retrying in seconds will not help. Serve the cached rate, alert, and resume after the reset time given in the X-Monthly-Resets header.

### Which errors are safe to retry?

Network errors, timeouts and 5xx responses. Do not retry 400, 401 or 403, because the same request will fail the same way.

### How stale can a cached exchange rate be?

It depends on the use. For display, a rate a few minutes old is usually fine, and an hour may be acceptable during an outage. For checkout or payments, set a tighter limit and stop selling in that currency rather than charge on a rate that is hours old.

### Can I use Frankfurter as a fallback?

Yes, as a daily fallback. Frankfurter is free and allows commercial use, but its data is daily, so treat its rates as older than a live feed and record which provider served each rate.

### How do I detect a bad rate?

Compare each new rate with the last good one and reject moves above a threshold, such as 10 percent for major pairs. Log the rejection and keep serving the last good value until someone checks.

## Rates refreshed every 60 seconds

Mid-market rates for 160+ currencies on every plan, with a time field on every pair so you can check staleness. Start with a free API key.

 [Get a free API key](https://allratestoday.com/register)

## Related Articles

- [How to cache exchange rates and avoid rate limits](https://allratestoday.com/blog/how-to-cache-exchange-rates-avoid-rate-limits/)
- [Currency API in TypeScript: a typed client](https://allratestoday.com/blog/currency-api-typescript/)
- [How often are exchange rates updated](https://allratestoday.com/blog/how-often-are-exchange-rates-updated/)
- [Exchange rate API response formats compared](https://allratestoday.com/blog/exchange-rate-api-response-formats-compared/)
- [API documentation](https://allratestoday.com/docs/)
