Free code template

API client with retries

A fetch wrapper with a timeout, retries with growing waits, and errors that say what failed.

It opens with this template loaded and runs in your browser. You don't need an account to use it; a free account saves your work and exports it.

The job it does

When your code calls another service that sometimes fails or is slow: payments, shipping, an internal API. It comes with its own checks, so after you change it you can see at a glance that it still does the job.

What's inside

This is the template exactly as the Code Lab opens it, read-only. Open it to run it, change it and export it.

  • 3 files
  • Runs from main.js
  • JavaScript
The brief: API client with retries

Your code calls a service that is sometimes slow or down. createClient wraps fetch with a timeout, retries with growing waits for failures that may pass on their own (dropped connections, 429, 5xx), no retry for the ones that will not (400, 401, 404), and an ApiError that says which call failed and how many times it tried.

The fetch is passed in, so api.test.js tests every rule without a network. Point baseUrl at your service and pass the real fetch in your app.

Done when
  • All my tests pass
  • Output contains “Booked SP-1042 after 3 tries”
  • Runs in 3,000 ms or less
  • No uncaught errors
api.js
// API client with retries: a timeout per request, retries with growing waits for failures that
// may pass on their own (network errors, 429, 5xx), and an error that says what failed.
// Pass the fetch to use, so the client can be tested without a network.

export class ApiError extends Error {
  constructor(message, { status, attempts, path }) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
    this.attempts = attempts;
    this.path = path;
  }
}

const RETRY_STATUS = new Set([408, 429, 500, 502, 503, 504]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function withTimeout(promise, ms) {
  let timer;
  const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`timed out after ${ms} ms`)), ms); });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

export function createClient({ baseUrl, fetch, timeoutMs = 5000, retries = 3, delayMs = 200 }) {
  async function request(method, path, body) {
    let wait = delayMs, last;
    for (let attempt = 1; attempt <= retries + 1; attempt++) {
      try {
        const res = await withTimeout(fetch(baseUrl + path, {
          method,
          headers: { 'content-type': 'application/json' },
          body: body === undefined ? undefined : JSON.stringify(body),
        }), timeoutMs);
        if (res.ok) return res.status === 204 ? null : res.json();
        last = new ApiError(`${method} ${path} failed with ${res.status}`, { status: res.status, attempts: attempt, path });
        if (!RETRY_STATUS.has(res.status)) throw last; // 400, 401, 404…: asking again will not help
      } catch (e) {
        if (e instanceof ApiError && !RETRY_STATUS.has(e.status)) throw e;
        last = e instanceof ApiError ? e : new ApiError(`${method} ${path}: ${e.message}`, { status: 0, attempts: attempt, path });
      }
      if (attempt <= retries) { await sleep(wait); wait *= 2; }
    }
    throw last;
  }
  return {
    get: (path) => request('GET', path),
    post: (path, body) => request('POST', path, body),
  };
}
api.test.js
import { createClient, ApiError } from './api';

// a fake fetch that answers from a list: a status number, or 'network' for a dropped connection
function fakeFetch(answers) {
  const calls = [];
  const fetch = async (url, init) => {
    calls.push(init.method + ' ' + url);
    const a = answers[Math.min(calls.length - 1, answers.length - 1)];
    if (a === 'network') throw new Error('connection reset');
    return { ok: a < 400, status: a, json: async () => ({ ok: true, call: calls.length }) };
  };
  return { fetch, calls };
}
const client = (answers, extra = {}) => {
  const f = fakeFetch(answers);
  return { api: createClient({ baseUrl: 'https://api.example.test', fetch: f.fetch, delayMs: 1, ...extra }), calls: f.calls };
};

test('returns the JSON of a successful call', async () => {
  const { api } = client([200]);
  expect(await api.get('/stock/42')).toEqual({ ok: true, call: 1 });
});
test('retries a 503 and a dropped connection, then succeeds', async () => {
  const { api, calls } = client([503, 'network', 200]);
  expect(await api.get('/stock/42')).toEqual({ ok: true, call: 3 });
  expect(calls).toHaveLength(3);
});
test('does not retry a 404', async () => {
  const { api, calls } = client([404, 200]);
  let err = null;
  try { await api.get('/nope'); } catch (e) { err = e; }
  expect(err instanceof ApiError && err.status === 404).toBe(true);
  expect(calls).toHaveLength(1);
});
test('gives up after the retries with the last error', async () => {
  const { api, calls } = client([500], { retries: 2 });
  let err = null;
  try { await api.post('/orders', { sku: 'tea' }); } catch (e) { err = e; }
  expect(err.message).toBe('POST /orders failed with 500');
  expect(calls).toHaveLength(3);
});
test('a call that hangs times out', async () => {
  const api = createClient({ baseUrl: '', fetch: () => new Promise(() => {}), timeoutMs: 20, retries: 0 });
  let err = null;
  try { await api.get('/slow'); } catch (e) { err = e; }
  expect(err.message).toContain('timed out after 20 ms');
});
main.js
import { createClient } from './api';

// The shipping partner's API answers "busy" twice, then works.
let n = 0;
const shippingFetch = async () => {
  n++;
  if (n < 3) return { ok: false, status: 503, json: async () => ({}) };
  return { ok: true, status: 200, json: async () => ({ parcel: 'SP-1042', status: 'booked' }) };
};

const shipping = createClient({ baseUrl: 'https://shipping.example.test', fetch: shippingFetch, delayMs: 10 });
shipping.post('/parcels', { order: 1042 })
  .then((r) => console.log('Booked', r.parcel, 'after', n, 'tries'))
  .catch((e) => console.log('Failed:', e.message));

The lesson behind it

Learn why it works, with a podcast and a quiz for each lesson.

  • CSV cleaner

    Read a CSV, trim and fix values, drop broken rows with a reason, and write clean CSV back.

  • Unit-test starter

    A function with a test file: the happy path, the edges and the errors, grouped and named clearly.

  • Utility script

    A small script with its options at the top, input checks, a summary line and tests.

All code templates · Every template