@mrzr/api-client

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.

On this page