# Recipes (/docs/recipes)



Short, complete patterns for things the client deliberately leaves to you.

## Retry with backoff [#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:

```ts
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 [#fetching-every-page]

```ts
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 [#polling-until-a-job-finishes]

```ts
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 [#one-request-for-identical-calls]

Ten components mounting at once, one request:

```ts
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 [#snake_case--camelcase]

```ts
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](/docs/plugins#writing-a-plugin).

## Validating responses with Zod [#validating-responses-with-zod]

```ts
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 [#a-fallback-that-never-breaks-the-app]

```ts
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 [#testing]

The client only needs `fetch`, so mock `fetch`, or use [MSW](https://mswjs.io). 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.

```ts
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](https://github.com/mohammadreza-zr/api-client/wiki/Cookbook).
