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.

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:

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

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.

Related

Originally published at allratestoday.com.