Exchange Rates API in Production: Fallbacks and Error Handling

Reviewed by Madhushan, Fintech Developer — November 2026

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.

FailureLooks likeRight response
Slow responseRequest hangsTimeout, then serve cache
Network error, 5xxfetch throws or res.status >= 500Retry with backoff, then fallback
401, 403Bad or revoked keyDo not retry. Alert. Serve cache
429Over plan limitDo not retry soon. Serve cache. Alert
Stale data200 OK, old timeTreat as degraded
Wrong data200 OK, rate far from last valueReject, 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.

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.

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:

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:

If you are picking a primary provider, the free currency API comparison covers update frequency and limits, and how often exchange rates are 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

Related Articles