Cookbook
Practical patterns and real-world usage examples
Practical patterns, copy-paste ready.
Retry with exponential backoff
The client retries a 401 exactly once. Everything else is your policy:
import { ApiError } from "@mrzr/api-client";
export async function withRetry<T>(
fn: () => Promise<T>,
{ attempts = 3, baseMs = 300 } = {},
): Promise<T> {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (e) {
lastError = e;
const retryable =
e instanceof ApiError &&
(e.statusCode >= 500 || e.statusCode === 0 || e.statusCode === 408 || e.statusCode === 429);
if (!retryable || i === attempts - 1) throw e;
const jitter = Math.random() * 100;
await new Promise((r) => setTimeout(r, baseMs * 2 ** i + jitter));
}
}
throw lastError;
}
const { data } = await withRetry(() => api.get<User[]>("/users"));Honouring Retry-After
catch (e) {
if (e instanceof ApiError && e.statusCode === 429) {
const after = Number(e.response.headers?.["retry-after"] ?? 1);
await new Promise((r) => setTimeout(r, after * 1000));
return fn();
}
throw e;
}Paginating everything
import type { ListResponse } from "@mrzr/api-client";
export async function* paginate<T>(path: string, params: Record<string, unknown> = {}) {
let page = 1;
while (true) {
const { data } = await api.get<ListResponse<T>>(path, { params: { ...params, page } });
if (!data?.results.length) return;
yield* data.results;
if (!data.next) return;
page++;
}
}
for await (const user of paginate<User>("/users", { active: true })) {
console.log(user.name);
}Collecting everything with a safety cap:
export async function collectAll<T>(path: string, max = 10_000): Promise<T[]> {
const out: T[] = [];
for await (const item of paginate<T>(path)) {
out.push(item);
if (out.length >= max) break;
}
return out;
}Cancelling a stale search
With the built-in registry this is one flag — no controller to track:
const api = createClient({ baseUrl, cancel: true });
export async function search(q: string) {
try {
const { data } = await api.get<Result[]>("/search", {
params: { q },
cancelKey: "search",
takeLatest: true, // retires the previous keystroke
});
return data ?? [];
} catch (e) {
if (e instanceof ApiError && e.canceled) return null; // superseded
throw e;
}
}Returning null for the canceled case lets the caller ignore stale results without treating them as failures.
Doing it by hand, if you prefer an explicit controller:
let inFlight: AbortController | undefined;
export async function search(q: string) {
inFlight?.abort();
inFlight = new AbortController();
try {
const { data } = await api.get<Result[]>("/search", {
params: { q },
signal: inFlight.signal,
hideErrorMessage: true,
});
return data ?? [];
} catch (e) {
if (e instanceof ApiError && e.canceled) return null;
throw e;
}
}Dropping in-flight requests on navigation
const api = createClient({ baseUrl, cancel: true });
// Next.js Pages Router
router.events.on("routeChangeStart", () => api.cancel());
// Vue Router
router.beforeEach((to, from, next) => {
if (to.path !== from.path) api.cancel();
next();
});
// Or narrow it to one screen's endpoints
api.cancel("/api/v1/products");A modal that cleans up after itself
function ProductModal({ id, onClose }: { id: string; onClose: () => void }) {
const scope = useMemo(() => api.cancelScope(`product-${id}`), [id]);
// everything the modal started dies with it
useEffect(() => () => scope.cancel(), [scope]);
useEffect(() => {
scope.get<Product>(`/api/v1/products/${id}`).then(setProduct).catch(skipCanceled);
scope.get<Review[]>(`/api/v1/products/${id}/reviews`).then(setReviews).catch(skipCanceled);
}, [scope, id]);
}
const skipCanceled = (e: unknown) => {
if (e instanceof ApiError && e.canceled) return;
throw e;
};See [[Cancellation]] for the full guide.
Deduplicating identical in-flight requests
const inFlight = new Map<string, Promise<unknown>>();
export function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = inFlight.get(key) as Promise<T> | undefined;
if (existing) return existing;
const promise = fn().finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}
// Ten components mounting at once → one request
const user = await dedupe(`user:${id}`, () =>
api.get<User>("/users/{id}", { addTemplateToUrl: { id } }),
);Polling until a job finishes
export async function pollJob(id: string, { intervalMs = 2000, timeoutMs = 300_000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { data } = await api.get<Job>("/jobs/{id}", {
addTemplateToUrl: { id },
hideErrorMessage: true,
});
if (data?.status === "done") return data;
if (data?.status === "failed") throw new Error(data.error ?? "Job failed");
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error("Job timed out");
}Optimistic updates with rollback
export async function toggleLike(post: Post, setPost: (p: Post) => void) {
const previous = post;
setPost({ ...post, liked: !post.liked, likes: post.likes + (post.liked ? -1 : 1) });
try {
const { data } = await api.post<Post>("/posts/{id}/like", null, {
addTemplateToUrl: { id: post.id },
});
if (data) setPost(data);
} catch (e) {
setPost(previous);
throw e;
}
}Concurrency-limited batch requests
export async function mapLimit<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const i = cursor++;
results[i] = await fn(items[i]);
}
});
await Promise.all(workers);
return results;
}
const users = await mapLimit(ids, 5, (id) =>
api.get<User>("/users/{id}", { addTemplateToUrl: { id } }).then((r) => r.data!),
);Tolerating partial failures:
const settled = await Promise.allSettled(ids.map((id) => users.byId(id)));
const found = settled.flatMap((r) => (r.status === "fulfilled" ? [r.value] : []));Snake_case ↔ camelCase conversion
const toCamel = (s: string) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
const toSnake = (s: string) => s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
function mapKeys(value: unknown, fn: (k: string) => string): unknown {
if (Array.isArray(value)) return value.map((v) => mapKeys(v, fn));
if (value && typeof value === "object" && value.constructor === Object) {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [fn(k), mapKeys(v, fn)]),
);
}
return value;
}
await api.post("/users", input, {
beforeFunc: (body) => mapKeys(body, toSnake),
afterFunc: (data) => mapKeys(data, toCamel),
});Applying it everywhere via a wrapper:
export function caseConverting(api: ApiClient): ApiClient {
const hooks = {
beforeFunc: (b: unknown) => mapKeys(b, toSnake),
afterFunc: (d: unknown) => mapKeys(d, toCamel),
};
return {
...api,
get: (u, c) => api.get(u, { ...hooks, ...c }),
post: (u, b, c) => api.post(u, b, { ...hooks, ...c }),
put: (u, b, c) => api.put(u, b, { ...hooks, ...c }),
patch: (u, b, c) => api.patch(u, b, { ...hooks, ...c }),
delete: (u, c) => api.delete(u, { ...hooks, ...c }),
} as ApiClient;
}Runtime response validation with Zod
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
export async function getUser(id: number) {
const { data } = await api.get("/users/{id}", { addTemplateToUrl: { id } });
return UserSchema.parse(data); // throws ZodError on a schema mismatch
}Or inside afterFunc, so validation is part of the request:
await api.get("/users", {
afterFunc: (data) => z.array(UserSchema).parse(data),
});Offline queue
type Queued = { method: "post" | "put" | "patch" | "delete"; url: string; body?: unknown };
const queue: Queued[] = JSON.parse(localStorage.getItem("queue") ?? "[]");
const save = () => localStorage.setItem("queue", JSON.stringify(queue));
export async function send(item: Queued) {
if (!navigator.onLine) { queue.push(item); save(); return; }
try {
await (api as any)[item.method](item.url, item.body);
} catch (e) {
if (e instanceof ApiError && e.statusCode === 0) { queue.push(item); save(); return; }
throw e;
}
}
window.addEventListener("online", async () => {
while (queue.length) {
const item = queue[0];
try { await (api as any)[item.method](item.url, item.body); queue.shift(); save(); }
catch { break; }
}
});Rate limiting yourself
function throttle(perSecond: number) {
const gap = 1000 / perSecond;
let next = 0;
return async () => {
const wait = Math.max(0, next - Date.now());
next = Date.now() + wait + gap;
if (wait) await new Promise((r) => setTimeout(r, wait));
};
}
const slot = throttle(10); // 10 requests/second
for (const id of ids) {
await slot();
await api.get(`/users/${id}`);
}Mocking in tests
The client only needs fetch, so mock at that level:
import { createClient } from "@mrzr/api-client";
function mockFetch(routes: Record<string, unknown>) {
globalThis.fetch = (async (input: RequestInfo) => {
const url = new URL(String(input));
const body = routes[url.pathname];
return new Response(JSON.stringify(body ?? { message: "Not found" }), {
status: body ? 200 : 404,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
}
test("lists users", async () => {
mockFetch({ "/users": { data: [{ id: 1, name: "Ada" }] } });
const api = createClient({ baseUrl: "http://test", worker: false, multiTab: false });
const { data } = await api.get<User[]>("/users");
expect(data).toEqual([{ id: 1, name: "Ada" }]);
api.destroy();
});Always use worker: false, multiTab: false in tests — jsdom's Worker and BroadcastChannel are unreliable, and an open channel can keep the runner alive.
With MSW:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
http.get("http://test/users", () => HttpResponse.json({ data: [{ id: 1 }] })),
);
beforeAll(() => server.listen());
afterAll(() => server.close());Feature flags with a safe fallback
const DEFAULTS = { newCheckout: false, betaSearch: false };
export async function loadFlags(): Promise<typeof DEFAULTS> {
const res = await api.get<typeof DEFAULTS>("/flags", {
throwError: false,
hideErrorMessage: true,
timeout: 2_000,
skipAuth: true,
});
return res.status ? { ...DEFAULTS, ...res.data } : DEFAULTS;
}A flags endpoint should never break the app, so this is a good use of the envelope style.
Health check on boot
export async function isApiReachable(): Promise<boolean> {
const res = await api.get("/health", {
throwError: false,
hideErrorMessage: true,
skipAuth: true,
timeout: 3_000,
});
return res.status;
}Downloading a file
export async function download(path: string, filename: string) {
const res = await api.get<Blob>(path, {
headers: { Accept: "application/octet-stream" },
fullData: true,
timeout: 0,
});
const blob = res.data instanceof Blob ? res.data : new Blob([String(res.data)]);
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), { href: url, download: filename });
a.click();
URL.revokeObjectURL(url);
}The client parses responses as JSON or text. For true binary downloads, calling
fetchdirectly is simpler — the auth header is the only thing you lose, and you can read it fromgetAuthState()-adjacent app state or use cookie mode.
Next: [[Migration Guide]]