Recipes
Copy-paste patterns for @mrzr/api-client: retry with backoff, pagination, polling, deduplication, case conversion, Zod validation and testing.
Short, complete patterns for things the client deliberately leaves to you.
Retry with backoff
The client retries a 401 once, after refreshing. It has no general retry option, because retries have to work with refresh and cancellation. Add your own policy:
import { ApiError } from "@mrzr/api-client";
export async function withRetry<T>(fn: () => Promise<T>, { attempts = 3, baseMs = 300 } = {}): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (e) {
const retryable =
e instanceof ApiError && !e.canceled && (e.statusCode >= 500 || [0, 408, 429].includes(e.statusCode));
if (!retryable || i >= attempts - 1) throw e;
const retryAfter = Number(e.response.headers?.["retry-after"]);
const wait = retryAfter > 0 ? retryAfter * 1000 : baseMs * 2 ** i + Math.random() * 100;
await new Promise((r) => setTimeout(r, wait));
}
}
}
const { data } = await withRetry(() => api.get<User[]>("/users"));Only retry writes that are safe to repeat. With TanStack Query, use its retry option instead.
Fetching every page
import type { ListResponse } from "@mrzr/api-client";
export async function* paginate<T>(path: string, params: Record<string, unknown> = {}) {
for (let page = 1; ; page++) {
const { data } = await api.get<ListResponse<T>>(path, { params: { ...params, page } });
if (!data?.results.length) return;
yield* data.results;
if (!data.next) return;
}
}
for await (const user of paginate<User>("/users", { active: true })) console.log(user.name);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 } });
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");
}One request for identical calls
Ten components mounting at once, one request:
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;
}
const user = await dedupe(`user:${id}`, () => api.get<User>("/users/{id}", { addTemplateToUrl: { id } }));snake_case ↔ camelCase
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),
});To apply it to every call, set both in a beforeRequest plugin.
Validating responses with Zod
import { z } from "zod";
const User = z.object({ id: z.number(), name: z.string(), email: z.string().email() });
const { data } = await api.get("/users", { afterFunc: (d) => z.array(User).parse(d) });If the schema doesn't match, the call fails like any other: it throws an ApiError with Zod's message and the response's status code (or resolves with status: false under throwError: false).
A fallback that never breaks the app
const DEFAULTS = { newCheckout: false };
export async function loadFlags() {
const res = await api.get<typeof DEFAULTS>("/flags", {
throwError: false, // resolve with the envelope instead of throwing
hideErrorMessage: true, // no error toast
skipAuth: true,
timeout: 2_000,
});
return res.status ? { ...DEFAULTS, ...res.data } : DEFAULTS;
}Testing
The client only needs fetch, so mock fetch, or use MSW. Always pass worker: false, multiTab: false in tests: test environments' Worker and BroadcastChannel are unreliable, and an open channel can keep the test runner alive. Destroy the client afterwards.
test("lists users", async () => {
globalThis.fetch = async () =>
new Response(JSON.stringify({ data: [{ id: 1, name: "Ada" }] }), {
headers: { "content-type": "application/json" },
});
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();
});More patterns (optimistic updates, offline queues, rate limiting, concurrency limits) are in the library wiki's cookbook.
Logging & Monitoring
Log requests with @mrzr/api-client, report errors to Sentry with onError, track auth events and add tracing headers, without ever logging a token.
Security
What @mrzr/api-client protects against and what it doesn't: token isolation, trusted origins, storage trade-offs, recommended setups and a CSP.