Project: a tested API client
Build a small REST client as a real package, in stages that each run, with fetch, retries, timeouts and cancellation, then test it with node:test and check it with ESLint and JSDoc types.
What you will learn
- Design a small client library around fetch with clear error types
- Retry only the failures that are worth retrying, with exponential backoff
- Combine a per-attempt timeout with a caller's cancellation signal
- Test a client against a local mock server, with waiting made fast and visible
- Lay the code out as a package with test, lint and typecheck scripts, and use ESLint and JSDoc checks
On this page
This project is the capstone of the intermediate level, built like a real package: a folder with a package.json, scripts, a linter and type checks. You write a small library that sits between a program and a web API, in stages that each run before you go on. It draws on promises and cancellation, fetch and servers, packages, the test runner and mocks, and the tools of the last two lessons: ESLint and JSDoc types.
createClient returns an object with get and post methods, and handles the chores in one place: JSON in and out, a bad HTTP status turned into an error (fetch does not), retries with exponential backoff for failures that may be temporary, a timeout for every attempt, and cancellation by the caller.
Design decisions first
Writing the rules down makes the tests obvious.
| Situation | What the client does |
|---|---|
2xx response |
returns the parsed JSON (or undefined for an empty body) |
4xx response |
throws HttpError; no retry, because a wrong request is wrong again |
5xx response |
throws HttpError after the retries are used up |
network failure (fetch failed) |
retried like a 5xx |
attempt takes longer than timeoutMs |
TimeoutError; retried |
| caller aborts the signal | AbortError; never retried, and it stops a backoff wait |
POST |
never retried, because repeating it may create two records. Only GET, HEAD, PUT and DELETE are safe to repeat |
| wait between retries | baseDelayMs, doubled each time: 100, 200, 400 … |
The last row is a testing decision too: the waiting function is a parameter (wait) with the real timer as the default, so tests can pass a stand-in instead of sitting through real delays.
The package
The project folder looks like this. Each file arrives in the stage that needs it.
api-client/ package.json eslint.config.mjs tsconfig.json src/ errors.mjs send.mjs retry.mjs client.mjs support/ api.mjs (the mock server) test/ client.test.mjs demo1.mjs demo2.mjs demo3.mjs (one per stage)Everything is .mjs, so Node treats it as an ES module. The package.json has the scripts you will use; exports names the one public file, and node --test finds the tests by itself.
{ "name": "api-client", "version": "1.0.0", "private": true, "type": "module", "exports": "./src/client.mjs", "scripts": { "test": "node --test", "lint": "eslint .", "typecheck": "tsc", "check": "node --run lint && node --run typecheck && node --run test" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^26.6.5", "eslint": "^10.13.0", "globals": "^17.13.0", "typescript": "^7.0.2" }}Run npm install to fetch the tools (versions of 17 October 2026). ESLint 10 needs Node 22.13 or later on the 22 line, hence this lesson’s minVersion. node --run check will pass once the stages below exist.
Stage 1: one attempt
First the error type. It carries the status and the parsed body of the failed response.
// @ts-checkexport class HttpError extends Error { /** * @param {number} status * @param {unknown} body */ constructor(status, body) { super(`HTTP ${status}`); this.name = "HttpError"; this.status = status; this.body = body; }}The // @ts-check line and JSDoc comments are the type information from the last lesson. send makes one attempt: a timeout combined with the caller’s signal by AbortSignal.any, the body read once, a bad status turned into an HttpError.
// @ts-checkimport { HttpError } from "./errors.mjs";
/** * One attempt: JSON in and out, a timeout, and the caller's signal. * @param {URL} url * @param {RequestInit} options * @param {{ timeoutMs: number, signal?: AbortSignal }} limits * @returns {Promise<unknown>} */export async function send(url, options, { timeoutMs, signal }) { const signals = [AbortSignal.timeout(timeoutMs)]; if (signal) signals.push(signal); const combined = AbortSignal.any(signals); const response = await fetch(url, { ...options, signal: combined }); const text = await response.text(); const data = text ? JSON.parse(text) : undefined; if (!response.ok) throw new HttpError(response.status, data); return data;}To try it you need a server you control. This in-memory users API lives in support/. stats counts requests, and behavior tells the server to fail the next few requests or answer slowly. It starts with a body reader and some state.
// @ts-nocheck (a test helper: the checker looks at src/ and the demos only)import { createServer } from "node:http";import { setTimeout as sleep } from "node:timers/promises";
async function readJson(req) { req.setEncoding("utf8"); let text = ""; for await (const chunk of req) text += chunk; return JSON.parse(text);}
export async function startApi() { const users = [{ id: 1, name: "Ada" }, { id: 2, name: "Alan" }]; const stats = { requests: 0 }; const behavior = { failures: 0, delayMs: 0 };The handler first applies the controls (delayMs waits, failures answers 503), then routes.
const server = createServer(async (req, res) => { stats.requests++; const send = (status, data) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(data)); }; if (behavior.delayMs > 0) await sleep(behavior.delayMs); if (behavior.failures > 0) { behavior.failures--; return send(503, { error: "busy" }); }The routes: the list, one user, and creating a user. Anything else is a 404.
const { pathname } = new URL(req.url, "http://localhost"); const one = pathname.match(/^\/users\/(\d+)$/); if (req.method === "GET" && pathname === "/users") return send(200, users); if (req.method === "GET" && one) { const user = users.find((u) => u.id === Number(one[1])); return user ? send(200, user) : send(404, { error: "not found" }); } if (req.method === "POST" && pathname === "/users") { const user = { id: users.length + 1, ...(await readJson(req)) }; users.push(user); return send(201, user); } send(404, { error: "not found" }); });Finally the server listens on a free port and the function returns the controls.
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const url = `http://127.0.0.1:${server.address().port}`; const stop = () => new Promise((resolve) => { server.close(resolve); server.closeAllConnections(); }); return { url, stop, stats, behavior };}Stage 1 runs. The demo fetches one user, then one that does not exist.
// @ts-checkimport { send } from "./src/send.mjs";import { HttpError } from "./src/errors.mjs";import { startApi } from "./support/api.mjs";
const api = await startApi();const limits = { timeoutMs: 1000 };
console.log(await send(new URL("/users/1", api.url), {}, limits));try { await send(new URL("/users/99", api.url), {}, limits);} catch (error) { if (error instanceof HttpError) console.log(error.status, error.body);}await api.stop();$ node demo1.mjs{ id: 1, name: 'Ada' }404 { error: 'not found' }Stage 2: retries
The second module decides which failures deserve another attempt. isRetryable encodes the table: a 5xx, a timeout, and fetch failed (the TypeError thrown when no response arrived). A cancellation is an AbortError, which matches nothing, so it is never retried.
// @ts-checkimport { setTimeout as sleep } from "node:timers/promises";import { HttpError } from "./errors.mjs";
/** * @typedef {{ signal?: AbortSignal }} WaitOptions * @typedef {{ attempt: number, delay: number, error: unknown }} RetryInfo * @typedef {object} RetryPolicy * @property {number} maxAttempts * @property {number} baseDelayMs * @property {AbortSignal} [signal] * @property {(ms: number, o: WaitOptions) => Promise<unknown>} [wait] * @property {(info: RetryInfo) => void} [onRetry] */A policy says how many attempts, how long to back off, and how to wait; isRetryable is the function that reads the table.
/** @param {unknown} error */export function isRetryable(error) { if (error instanceof HttpError) return error.status >= 500; if (!(error instanceof Error)) return false; return error.name === "TimeoutError" || error.message === "fetch failed";}withRetries makes an attempt. On a retryable failure with attempts left, it computes the delay (baseDelayMs * 2 ** (n - 1)), reports it to onRetry, waits and tries again. The wait gets the caller’s signal, so a cancel during the pause ends the call.
/** * Run `attempt` again after a retryable failure, waiting longer each time. * @template T * @param {() => Promise<T>} attempt * @param {RetryPolicy} policy * @returns {Promise<T>} */export async function withRetries(attempt, policy) { const { maxAttempts, baseDelayMs, signal, onRetry } = policy; const wait = policy.wait ?? ((ms, o) => sleep(ms, undefined, o));The loop itself:
for (let n = 1; ; n++) { try { return await attempt(); } catch (error) { if (n >= maxAttempts || !isRetryable(error)) throw error; const delay = baseDelayMs * 2 ** (n - 1); onRetry?.({ attempt: n, delay, error }); await wait(delay, { signal }); } }}The loop ends by return on success or throw on a final error. The second demo makes the server fail twice, with short delays.
// @ts-checkimport { send } from "./src/send.mjs";import { withRetries } from "./src/retry.mjs";import { startApi } from "./support/api.mjs";
const api = await startApi();api.behavior.failures = 2;
const url = new URL("/users/2", api.url);const attempt = () => send(url, {}, { timeoutMs: 1000 });const user = await withRetries(attempt, { maxAttempts: 3, baseDelayMs: 5, onRetry: ({ attempt, delay }) => { console.log(`retry ${attempt} in ${delay} ms`); },});console.log(user);await api.stop();$ node demo2.mjsretry 1 in 5 msretry 2 in 10 ms{ id: 2, name: 'Alan' }Stage 3: the client
The last module joins the two. request builds the URL and body, decides how many attempts the method allows (a POST gets one), and hands one attempt to withRetries. A @typedef documents the options for everyone, including the editor.
// @ts-checkimport { send } from "./send.mjs";import { withRetries } from "./retry.mjs";
const SAFE_TO_REPEAT = new Set(["GET", "HEAD", "PUT", "DELETE"]);
/** * @typedef {import("./retry.mjs").RetryPolicy} RetryPolicy * @typedef {object} ClientOptions * @property {string} baseUrl * @property {number} [retries] retries after the first attempt (default 2) * @property {number} [timeoutMs] limit for each attempt (default 5000) * @property {number} [baseDelayMs] first back-off delay (default 100) * @property {RetryPolicy["wait"]} [wait] * @property {RetryPolicy["onRetry"]} [onRetry] */createClient fills in the defaults and defines request, which builds the URL and body.
/** @param {ClientOptions} options */export function createClient(options) { const { baseUrl, retries = 2, timeoutMs = 5000, baseDelayMs = 100 } = options;
/** * @param {string} method * @param {string} path * @param {{ body?: unknown, signal?: AbortSignal }} [extra] */ async function request(method, path, { body, signal } = {}) { const url = new URL(path, baseUrl); /** @type {RequestInit} */ const init = { method }; if (body !== undefined) { init.headers = { "content-type": "application/json" }; init.body = JSON.stringify(body); }The rest of request and the public methods:
const maxAttempts = SAFE_TO_REPEAT.has(method) ? retries + 1 : 1; const policy = { ...options, maxAttempts, baseDelayMs, signal }; return withRetries(() => send(url, init, { timeoutMs, signal }), policy); }
/** @typedef {{ signal?: AbortSignal }} Extra */ return { /** @param {string} path @param {Extra} [extra] */ get: (path, extra) => request("GET", path, extra), /** * @param {string} path * @param {unknown} body * @param {Extra} [extra] */ post: (path, body, extra) => request("POST", path, { ...extra, body }), };}The third demo uses the finished client: a GET that meets one 503, and a POST.
// @ts-checkimport { createClient } from "./src/client.mjs";import { startApi } from "./support/api.mjs";
const api = await startApi();const client = createClient({ baseUrl: api.url, baseDelayMs: 5, onRetry: ({ delay, error }) => console.log(`retry in ${delay} ms: ${error}`),});
api.behavior.failures = 1;console.log(await client.get("/users"));console.log(await client.post("/users", { name: "Grace" }));await api.stop();$ node demo3.mjsretry in 5 ms: HttpError: HTTP 503[ { id: 1, name: 'Ada' }, { id: 2, name: 'Alan' } ]{ id: 3, name: 'Grace' }Stage 4: tests
Now the safety net: one test per row of the design table. The server starts once in before and stops in after; beforeEach resets the controls so tests cannot disturb one another.
import { describe, it, before, after, beforeEach, mock } from "node:test";import assert from "node:assert/strict";import { createClient } from "../src/client.mjs";import { HttpError } from "../src/errors.mjs";import { startApi } from "../support/api.mjs";
describe("api client", () => { let api; before(async () => { api = await startApi(); }); after(() => api.stop()); beforeEach(() => { api.stats.requests = 0; api.behavior.failures = 0; api.behavior.delayMs = 0; });
const client = (options) => createClient({ baseUrl: api.url, ...options });The request counter proves the 404 is not retried: the server saw one request.
it("returns the parsed JSON of a successful GET", async () => { assert.deepEqual(await client().get("/users/1"), { id: 1, name: "Ada" }); });
it("throws an HttpError for a 404, without retrying", async () => { const error = await client().get("/users/99").catch((e) => e); assert.ok(error instanceof HttpError); assert.equal(error.status, 404); assert.deepEqual(error.body, { error: "not found" }); assert.equal(api.stats.requests, 1); });For the retries, wait is a spy that returns at once. The test checks the delays requested, which is the backoff schedule.
it("retries a 503 with doubling delays", async () => { api.behavior.failures = 2; const wait = mock.fn(async () => {}); const result = await client({ wait }).get("/users/2");
assert.deepEqual(result, { id: 2, name: "Alan" }); assert.equal(api.stats.requests, 3); const delays = wait.mock.calls.map((call) => call.arguments[0]); assert.deepEqual(delays, [100, 200]); });
it("gives up after the allowed retries", async () => { api.behavior.failures = 10; const failing = client({ wait: async () => {}, retries: 2 }); const error = await failing.get("/users").catch((e) => e);
assert.equal(error.status, 503); assert.equal(api.stats.requests, 3); });Three requests for retries: 2: the first attempt plus two retries. Last, the body and the clock:
it("sends a POST body as JSON", async () => { const created = await client().post("/users", { name: "Grace" }); assert.deepEqual(created, { id: 3, name: "Grace" }); });
it("times out a slow answer", async () => { api.behavior.delayMs = 200; const slow = client({ timeoutMs: 20, retries: 0 }); const error = await slow.get("/users").catch((e) => e); assert.equal(error.name, "TimeoutError"); });});Run node --run test. The listing uses the spec format without durations; from Node 23 that is what you get, but on Node 22 a piped run prints TAP unless you add --test-reporter=spec.
▶ api client ✔ returns the parsed JSON of a successful GET ✔ throws an HttpError for a 404, without retrying ✔ retries a 503 with doubling delays ✔ gives up after the allowed retries ✔ sends a POST body as JSON ✔ times out a slow answer✔ api clientℹ tests 6ℹ suites 1ℹ pass 6ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0Read the tests as a specification. The two that matter most, no retry on a 404 and exponential backoff, also check the server’s request count: a test of the result alone could not tell one retry from ten. To see which lines no test reached, add --experimental-test-coverage as in the testing lesson.
Stage 5: lint and types
The package is finished only when its checks pass. The ESLint config is the one from the linting lesson, with eqeqeq and prefer-const on:
import { defineConfig } from "eslint/config";import js from "@eslint/js";import globals from "globals";
export default defineConfig([ { ignores: ["node_modules/"] }, { files: ["**/*.mjs"], extends: [js.configs.recommended], languageOptions: { globals: globals.node }, rules: { eqeqeq: "error", "prefer-const": "error" }, },]);The checker’s config is the one from the types lesson, pointed at src/ and the demos:
{ "compilerOptions": { "allowJs": true, "checkJs": true, "noEmit": true, "strict": true, "target": "esnext", "module": "nodenext", "types": ["node"] }, "include": ["src/*.mjs", "demo*.mjs"]}Both pass on the finished code. Now make a mistake that no test catches: in demo3.mjs, write baseDelayMs: "5". The program still prints the same lines, because JavaScript multiplies the string. The checker sees @property {number} [baseDelayMs] and refuses (TypeScript 7.0.2, 17 October 2026). Change it back and all is green.
$ node --run typecheckdemo3.mjs(8,3): error TS2322: Type 'string' is not assignable to type 'number'.$ node --run lint$ node --run typechecknode --run check runs lint, types and tests and stops at the first failure: the command for CI, and before every commit.
What you can do now
You have finished the intermediate level. In a sentence per module:
- Objects and classes:
this, prototypes, private fields, composition. - Iteration and collections: iterators, generators, helpers, the right
MaporSet. - Asynchronous JavaScript: the event loop, promises,
async, cancellation, errors. - Text, dates and numbers: regular expressions, Unicode,
Intl,Date,Temporal. - Node.js essentials: events, files, streams, HTTP, command-line tools, npm.
- Testing and tooling:
node:test, mocks, fake timers, ESLint, Prettier, types.
The advanced level starts with metaprogramming: Proxy and Reflect, tagged templates and functional patterns. Or extend this project first with the exercises.
Improving it
- Honour a
Retry-Afterheader on a503. - Add random jitter to the delay, injected like
waitso tests stay exact.
Common pitfalls
- Retrying everything. Repeating a
4xxonly repeats the mistake. Repeating aPOSTcan duplicate data. - Treating
AbortErroras a failure. The caller asked for it. Do not retry it or log it as an error. - Tests that wait in real time, or check only the result. Inject the waiting, and check the number of requests as well.
- Leaving the server running at the end of the tests, or no timeout on
fetch.
Exercises
Add test/ex_cancel.test.mjs to the package. Its test, does not retry a cancelled request, makes the server answer after 300 ms, starts client.get("/users", { signal }) with an AbortController’s signal, and waits until the server has received the request (api.stats.requests is 1) before aborting. It checks that the call rejects with an error named AbortError and that the server saw exactly one request. Run node --run test.
Expected output of the new file alone (node --test test/cancel.test.mjs):
✔ does not retry a cancelled requestℹ tests 1ℹ suites 0ℹ pass 1ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0Show solution
Waiting for the request counter makes the test independent of timing: the abort always lands while the server is holding the request. With retries at its default of 2, a client that wrongly retried an abort would send more requests, and the counter would show it. The server and client are created inside the test and cleaned up in a finally block, the pattern for a test with a custom set-up.
import { it } from "node:test";import assert from "node:assert/strict";import { setTimeout as sleep } from "node:timers/promises";import { createClient } from "../src/client.mjs";import { startApi } from "../support/api.mjs";
it("does not retry a cancelled request", async () => { const api = await startApi(); try { api.behavior.delayMs = 300; const controller = new AbortController(); const client = createClient({ baseUrl: api.url, baseDelayMs: 1 }); const call = client.get("/users", { signal: controller.signal });
while (api.stats.requests === 0) await sleep(1); controller.abort(); const error = await call.catch((e) => e);
assert.equal(error.name, "AbortError"); assert.equal(api.stats.requests, 1); } finally { await api.stop(); }});✔ does not retry a cancelled requestℹ tests 1ℹ suites 0ℹ pass 1ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0Add a test called does not repeat a POST after a 503. The server is told to fail once. The client posts { name: "Edsger" }. Check that the call rejects with an HttpError whose status is 503, and that the server received exactly one request. Then check that the same failure on a GET is repeated: with the server told to fail once again, a GET succeeds after two requests in total.
Expected output of the new file alone:
✔ does not repeat a POST after a 503ℹ tests 1ℹ suites 0ℹ pass 1ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0Show solution
The injected wait that returns at once keeps the test fast. The counter is reset between the two halves of the test.
import { it } from "node:test";import assert from "node:assert/strict";import { createClient } from "../src/client.mjs";import { HttpError } from "../src/errors.mjs";import { startApi } from "../support/api.mjs";
it("does not repeat a POST after a 503", async () => { const api = await startApi(); try { const client = createClient({ baseUrl: api.url, wait: async () => {} });
api.behavior.failures = 1; const posting = client.post("/users", { name: "Edsger" }); const error = await posting.catch((e) => e); assert.ok(error instanceof HttpError); assert.equal(error.status, 503); assert.equal(api.stats.requests, 1);
api.stats.requests = 0; api.behavior.failures = 1; await client.get("/users"); assert.equal(api.stats.requests, 2); } finally { await api.stop(); }});✔ does not repeat a POST after a 503ℹ tests 1ℹ suites 0ℹ pass 1ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0Many APIs return a long list in pages. Write a small server whose GET /items?page=N returns { "items": [...], "next": N+1 } for pages 1 and 2, and { "items": [...], "next": null } for page 3: the items are the letters a and b on page 1, c and d on page 2 and e on page 3. Write an async generator paginate(client, path) that yields each item and follows next until it is null. Use createClient from the package. Collect everything with Array.fromAsync and print the list.
Expected output:
[ 'a', 'b', 'c', 'd', 'e' ]Show solution
The generator asks for one page at a time and yields its items before asking for the next, so a consumer that stops early never downloads the remaining pages. Array.fromAsync, from the async iteration lesson, runs it to the end.
import { createServer } from "node:http";import { createClient } from "./src/client.mjs";
const pages = { 1: ["a", "b"], 2: ["c", "d"], 3: ["e"] };
const server = createServer((req, res) => { const { searchParams } = new URL(req.url, "http://localhost"); const page = Number(searchParams.get("page")); const next = pages[page + 1] ? page + 1 : null; res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ items: pages[page], next }));});await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
async function* paginate(client, path) { let page = 1; while (page !== null) { const result = await client.get(`${path}?page=${page}`); yield* result.items; page = result.next; }}
const baseUrl = `http://127.0.0.1:${server.address().port}`;const client = createClient({ baseUrl });console.log(await Array.fromAsync(paginate(client, "/items")));server.close();server.closeAllConnections();[ 'a', 'b', 'c', 'd', 'e' ]Summary
- A client library centralises the chores around
fetch: JSON, status checks as errors, retries, timeouts and cancellation. - Decide the rules first. Retry only what may be temporary, never a
4xx, a cancellation or aPOST. - Make the awkward part replaceable (here
wait), so tests check the backoff schedule without sitting through it. - Test against a local mock server you can tell to fail or slow down, count its requests, and stop it in
after. - Lay the code out as a package with
test,lintandtypecheckscripts, and run them withnode --run check.
Next: the advanced level, starting with metaprogramming.