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
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.
- All my tests pass
- Output contains “Booked SP-1042 after 3 tries”
- Runs in 3,000 ms or less
- No uncaught errors
// 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),
};
}
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');
});
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.
More code templates
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.