Как делать12 октября 2026 г.5 мин чтения
Готовые решенияШаг 9 из 11

Рецепт: типизированный API-клиент на fetch

Коротко

Типизированный API-клиент — это тонкая обёртка над fetch, через которую проходят все запросы проекта: она собирает URL от базового адреса, ставит общие заголовки, ограничивает время ожидания, проверяет ответ схемой Zod и превращает любые сбои в один класс ApiError с разобранным Problem Details. Рецепт даёт готовый файл api-client.ts около 180 строк, пример модуля API и тесты против локального HTTP-сервера.

Рецепт кодаУровень: СреднийРецепты для APIПрактика: ~20–40 мин

После материала вы сможете

  • подключить один клиент над fetch с базовым URL, общими заголовками и таймаутом на попытку
  • типизировать ответы схемами Zod так, чтобы тип и проверка во время выполнения не расходились
  • разбирать ошибки application/problem+json (RFC 9457) в единый класс ApiError и отличать их от отмены запроса
  • повторять при сбое только идемпотентные запросы и POST с ключом идемпотентности

Проверено на

TypeScript 7.0.2 (strict)Zod 4.6Fetch API, AbortSignal.timeout / AbortSignal.anyNode.js 24 LTS (24.18)Vitest 5.0.3

Сверено с документацией: 12 октября 2026 г.

Содержание
  1. 01Задача: разрозненные fetch по проекту
  2. 02Готовый код: api-client.ts
  3. 03Как подключить: модули API и сообщения об ошибках
  4. 04Как проверить
  5. 05Ограничения
  6. 06Связанные материалы и источники

Задача: разрозненные fetch по проекту

В растущем проекте fetch вызывается из десятков мест, и в каждом свои ошибки. MDN напоминает главную: fetch() отклоняет промис только при сетевых сбоях, а ответ 404 или 500 приходит как обычный Response — если забыть проверить response.ok, ошибка сервера уйдёт в интерфейс как данные. Собственного параметра таймаута у fetch нет, а запись (await res.json()) as User — обещание компилятору, которое никто не проверяет: если бэкенд переименует поле, сломается место, далёкое от запроса.

Базовый адрес копируется по проекту

Решение в клиенте
baseUrl задаётся один раз, путь склеивается с ним без потери /v1

Токен и общие заголовки в каждом вызове

Решение в клиенте
headers — объект или функция, вызываемая перед каждой попыткой

Забыт response.ok

Решение в клиенте
Любой ответ не из 2xx превращается в ApiError с кодом и Problem Details

Тип ответа «на честном слове»

Решение в клиенте
Ответ проверяется схемой Zod, тип выводится из неё же

Запрос висит бесконечно

Решение в клиенте
AbortSignal.timeout() на каждую попытку и AbortSignal.any() для отмены вызывающим кодом

Повтор создаёт дубли

Решение в клиенте
Повторяются только идемпотентные методы и POST/PATCH с Idempotency-Key
Что берёт на себя клиент

Клиент намеренно тонкий: без собственного кэша, состояния и зависимостей, кроме Zod. Кэширование и состояние загрузки — задача слоя данных интерфейса; где этот слой живёт, разобрано в материале об архитектуре фронтенда.

Готовый код: api-client.ts

bash
npm init -y
npm pkg set type=module
npm install zod@4
npm install -D typescript @types/node@24 vitest
Зависимости; версии, на которых проверен рецепт, — в шапке
tsconfig.json
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Строгий режим обязателен: документация Zod требует strict: true
src/api-client.ts
import { z } from "zod";

// RFC 9457: a member of the wrong type is ignored, unknown members are extensions
const ProblemDetailsSchema = z.looseObject({
  type: z.string().catch("about:blank").default("about:blank"),
  title: z.string().optional().catch(undefined),
  status: z.number().int().optional().catch(undefined),
  detail: z.string().optional().catch(undefined),
  instance: z.string().optional().catch(undefined),
});
export type ProblemDetails = z.output<typeof ProblemDetailsSchema>;

export type ApiErrorKind = "http" | "timeout" | "network" | "invalid-response";

// One error class for everything that can go wrong with a call.
// Cancellation by the caller is not an ApiError: the AbortError is rethrown as is.
export class ApiError extends Error {
  readonly kind: ApiErrorKind;
  readonly method: string;
  readonly url: string;
  readonly status: number | undefined;
  readonly problem: ProblemDetails | undefined;

  constructor(
    message: string,
    details: { kind: ApiErrorKind; method: string; url: string; status?: number; problem?: ProblemDetails; cause?: unknown },
  ) {
    super(message, { cause: details.cause });
    this.name = "ApiError";
    this.kind = details.kind;
    this.method = details.method;
    this.url = details.url;
    this.status = details.status;
    this.problem = details.problem;
  }
}

type HeadersProvider = HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
type Query = Record<string, string | number | boolean | undefined>;

export type ApiClientOptions = {
  baseUrl: string; // "https://api.example.com/v1"
  headers?: HeadersProvider; // a function is called before every attempt: fresh token
  timeoutMs?: number; // per attempt
  retries?: number; // extra attempts, only for idempotent requests
  retryDelayMs?: number;
  fetch?: typeof fetch;
};

export type RequestOptions<S extends z.ZodType> = {
  schema: S; // checks the response body and gives it a type
  query?: Query;
  body?: unknown; // sent as JSON
  headers?: HeadersInit;
  signal?: AbortSignal; // cancellation by the caller
  timeoutMs?: number;
  idempotencyKey?: string; // makes POST and PATCH retryable if the API supports it
};

const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]);
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);

function buildUrl(baseUrl: string, path: string, query: Query = {}): URL {
  // new URL("users", "https://x/v1") drops "/v1": keep the base path with a trailing slash
  const url = new URL(path.replace(/^\/+/, ""), baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
  for (const [key, value] of Object.entries(query)) {
    if (value !== undefined) url.searchParams.set(key, String(value));
  }
  return url;
}

async function readProblem(res: Response): Promise<ProblemDetails | undefined> {
  if (!res.headers.get("content-type")?.includes("application/problem+json")) {
    await res.body?.cancel();
    return undefined;
  }
  const parsed = ProblemDetailsSchema.safeParse(await res.json().catch(() => null));
  return parsed.success ? parsed.data : undefined;
}

function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(signal.reason);
    const timer = setTimeout(done, ms);
    function done() {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }
    function onAbort() {
      clearTimeout(timer);
      reject(signal?.reason);
    }
    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

export function createApiClient(options: ApiClientOptions) {
  const { baseUrl, timeoutMs = 10_000, retries = 2, retryDelayMs = 200, fetch: doFetch = fetch } = options;

  async function attempt<S extends z.ZodType>(method: string, url: URL, opts: RequestOptions<S>): Promise<z.output<S>> {
    const shared = typeof options.headers === "function" ? await options.headers() : options.headers;
    const headers = new Headers(shared);
    new Headers(opts.headers).forEach((value, key) => headers.set(key, value));
    headers.set("Accept", "application/json, application/problem+json");
    if (opts.idempotencyKey) headers.set("Idempotency-Key", opts.idempotencyKey);
    if (opts.body !== undefined) headers.set("Content-Type", "application/json");

    const timeout = AbortSignal.timeout(opts.timeoutMs ?? timeoutMs);
    const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
    const where = { method, url: url.toString() };

    try {
      const res = await doFetch(url, {
        method,
        headers,
        body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
        signal,
      });
      if (!res.ok) {
        const problem = await readProblem(res);
        const reason = problem?.title ?? res.statusText;
        throw new ApiError(`${method} ${url.pathname} answered ${res.status}${reason ? `: ${reason}` : ""}`, {
          kind: "http", status: res.status, problem, ...where,
        });
      }
      const data: unknown = res.status === 204 ? undefined : await res.json();
      const parsed = opts.schema.safeParse(data);
      if (!parsed.success) {
        throw new ApiError(`${method} ${url.pathname}: unexpected response\n${z.prettifyError(parsed.error)}`, {
          kind: "invalid-response", status: res.status, cause: parsed.error, ...where,
        });
      }
      return parsed.data;
    } catch (error) {
      if (error instanceof ApiError) throw error;
      if (opts.signal?.aborted) throw opts.signal.reason; // the caller cancelled: not our failure
      if (timeout.aborted) {
        throw new ApiError(`${method} ${url.pathname}: no response in ${opts.timeoutMs ?? timeoutMs} ms`, {
          kind: "timeout", cause: error, ...where,
        });
      }
      if (error instanceof SyntaxError) {
        throw new ApiError(`${method} ${url.pathname}: response is not JSON`, { kind: "invalid-response", cause: error, ...where });
      }
      throw new ApiError(`${method} ${url.pathname}: network error`, { kind: "network", cause: error, ...where });
    }
  }

  async function request<S extends z.ZodType>(method: string, path: string, opts: RequestOptions<S>): Promise<z.output<S>> {
    const url = buildUrl(baseUrl, path, opts.query);
    // RFC 9110: repeat automatically only idempotent requests (or ones with an idempotency key)
    const maxAttempts = IDEMPOTENT_METHODS.has(method) || opts.idempotencyKey ? retries + 1 : 1;

    for (let n = 1; ; n++) {
      try {
        return await attempt(method, url, opts);
      } catch (error) {
        const transient =
          error instanceof ApiError &&
          (error.kind === "network" || error.kind === "timeout" || RETRYABLE_STATUS.has(error.status ?? 0));
        if (!transient || n >= maxAttempts) throw error;
        // Simplified pause; Retry-After, jitter and an overall deadline are in the external API lesson
        await sleep(retryDelayMs * 2 ** (n - 1), opts.signal);
      }
    }
  }

  return {
    get: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("GET", path, opts),
    post: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("POST", path, opts),
    put: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("PUT", path, opts),
    patch: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("PATCH", path, opts),
    delete: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("DELETE", path, opts),
  };
}

export type ApiClient = ReturnType<typeof createApiClient>;
Клиент целиком: URL, заголовки, таймаут, отмена, Problem Details, схема ответа и повтор
  • buildUrl: new URL("users", "https://api.example.com/v1") даёт https://api.example.com/users — относительный путь заменяет последний сегмент базы. Поэтому к базе добавляется завершающий /, а у пути убирается ведущий. Параметры со значением undefined в строку запроса не попадают.
  • Accept: application/json, application/problem+json — тот же заголовок, что в примере RFC 9457. Ошибку клиент разбирает, только если сервер ответил с типом application/problem+json; тело других ошибок не читается.
  • Схема ProblemDetailsSchema следует RFC 9457: поле неверного типа игнорируется (.catch(undefined)), отсутствующий type считается about:blank, а неизвестные поля — расширения вроде balance или errors — сохраняются благодаря z.looseObject.
  • Таймаут — AbortSignal.timeout(), по истечении он прерывает запрос с TimeoutError; AbortSignal.any() объединяет его с сигналом вызывающего кода. Сигнал действует и на чтение тела: зависшая передача JSON тоже прервётся.
  • Отмена вызывающим кодом — не ошибка API: наружу уходит исходный AbortError, без повторов. Интерфейс отличает «пользователь ушёл со страницы» от «сервер не ответил».
  • Ответ проверяется schema.safeParse; при несовпадении ApiError с видом invalid-response содержит читаемый текст z.prettifyError — его удобно отправлять в логи.
  • Повтор упрощён: экспоненциальная пауза без разброса. RFC 9110 разрешает автоматически повторять идемпотентные методы, а для остальных — только если известно, что повтор безопасен; это и есть ключ идемпотентности. Retry-After, jitter, общий срок операции и circuit breaker разобраны в материале «Внешние API: таймауты, повторы, лимиты и деградация» — не дублируйте их здесь, а при необходимости передайте ту обёртку в опцию fetch.

Как подключить: модули API и сообщения об ошибках

  1. Схемы рядом с вызовами

    Для каждого ресурса — модуль со схемами Zod и функциями: users.get, users.list. Тип выводится из схемы через z.output, отдельный interface не нужен.

  2. Один клиент на API

    Точка сборки создаёт клиент с базовым URL из конфигурации и функцией получения токена и отдаёт наружу уже типизированные модули.

  3. Компоненты зовут модули

    Интерфейс вызывает api.users.get(id, signal) и получает User с createdAt типа Date — преобразование сделала схема.

  4. Ошибки — в тексты интерфейса

    catch проверяет instanceof ApiError и выбирает сообщение по виду ошибки, коду и type. AbortError пользователю не показывается.

src/users.ts
import { z } from "zod";
import type { ApiClient } from "./api-client.ts";

// The schema is both the runtime check and the TypeScript type
export const User = z.object({
  id: z.string(),
  name: z.string(),
  email: z.email(),
  createdAt: z.iso.datetime().transform((value) => new Date(value)),
});
export type User = z.output<typeof User>;

const UserPage = z.object({
  items: z.array(User),
  nextCursor: z.string().nullable(),
});

export function makeUsersApi(api: ApiClient) {
  return {
    get: (id: string, signal?: AbortSignal) =>
      api.get(`users/${encodeURIComponent(id)}`, { schema: User, signal }),
    list: (cursor?: string) =>
      api.get("users", { schema: UserPage, query: { cursor, limit: 20 } }),
    // One key per operation: a retry of the same POST does not create a second user
    create: (input: { name: string; email: string }) =>
      api.post("users", { schema: User, body: input, idempotencyKey: crypto.randomUUID() }),
    remove: (id: string) =>
      api.delete(`users/${encodeURIComponent(id)}`, { schema: z.undefined() }), // 204 No Content
  };
}
Модуль ресурса: схема — и проверка, и тип; POST получает ключ идемпотентности
src/api.ts
import { createApiClient } from "./api-client.ts";
import { makeUsersApi } from "./users.ts";

// Composition point: one client per API, typed modules on top of it.
// Where the token comes from (session, refresh flow) is the app's decision.
export function createApi(baseUrl: string, getToken: () => Promise<string | null>) {
  const client = createApiClient({
    baseUrl,
    timeoutMs: 8_000,
    headers: async (): Promise<HeadersInit> => {
      const token = await getToken();
      return token ? { Authorization: `Bearer ${token}` } : {};
    },
  });
  return { users: makeUsersApi(client) };
}
Точка сборки: клиент, токен и типизированные модули
src/error-message.ts
import type { ApiError } from "./api-client.ts";

// User texts come from your own translations, keyed by kind, status and the problem "type" URI.
// "detail" is for logs and debugging: do not parse it and do not show it as is.
export function errorMessage(error: ApiError): string {
  if (error.kind === "timeout" || error.kind === "network") {
    return "The server is not responding. Check the connection and try again.";
  }
  if (error.kind === "invalid-response" || (error.status ?? 500) >= 500) {
    return "Something went wrong on our side. We are already looking into it.";
  }
  if (error.status === 401) return "Your session has expired. Please sign in again.";
  if (error.problem?.type === "https://api.example.com/problems/email-taken") {
    return "This email is already registered.";
  }
  return "The request could not be completed.";
}
Сообщение для пользователя по виду ошибки, коду и типу проблемы

Код клиента и модулей использует только стандартные fetch, URL, Headers и AbortSignal, поэтому работает и в браузере, и на сервере. При проверке рецепта папка src дополнительно прошла tsc с библиотеками dom и без типов Node.js. В браузере добавляются ограничения CORS: запросы к другому домену должен разрешить сервер.

Как проверить

Тесты идут против настоящего HTTP-сервера node:http на случайном порту: клиент проходит весь сетевой путь, а сценарий сервера меняется в каждом тесте. Сервер записывает полученные запросы — по ним проверяются URL, заголовки, тело и число попыток.

test/server.ts
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";

export type Handler = (req: IncomingMessage, res: ServerResponse, n: number) => void;
export type Recorded = { method: string; url: string; headers: IncomingHttpHeaders; body: string };

// A real HTTP server on a random local port: the client goes through the network stack
export async function startServer() {
  let handler: Handler = (_req, res) => void res.writeHead(404).end();
  const requests: Recorded[] = [];
  const server = createServer(async (req, res) => {
    let body = "";
    for await (const chunk of req) body += chunk;
    requests.push({ method: req.method ?? "", url: req.url ?? "", headers: req.headers, body });
    handler(req, res, requests.length);
  });
  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
  const { port } = server.address() as AddressInfo;

  return {
    baseUrl: `http://127.0.0.1:${port}/v1`,
    requests,
    handle(next: Handler) {
      handler = next;
      requests.length = 0;
    },
    close: () =>
      new Promise<void>((resolve) => {
        server.closeAllConnections();
        server.close(() => resolve());
      }),
  };
}

export function send(res: ServerResponse, status: number, data: unknown, type = "application/json") {
  if (res.destroyed) return; // the client has already given up
  res.writeHead(status, { "Content-Type": type }).end(typeof data === "string" ? data : JSON.stringify(data));
}
Локальный сервер для тестов: обработчик задаётся в тесте, запросы записываются
test/api-client.test.ts
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { z } from "zod";
import { ApiError, createApiClient } from "../src/api-client.ts";
import { createApi } from "../src/api.ts";
import { errorMessage } from "../src/error-message.ts";
import { send, startServer } from "./server.ts";

let server: Awaited<ReturnType<typeof startServer>>;
beforeAll(async () => {
  server = await startServer();
});
afterAll(() => server.close());

const anna = { id: "u1", name: "Anna", email: "anna@example.com", createdAt: "2026-10-12T09:00:00Z" };
const api = () => createApi(server.baseUrl, async () => "test-token");
const fail = (promise: Promise<unknown>) => promise.then(() => expect.fail("should reject"), (error: unknown) => error);

describe("typed API client", () => {
  it("joins the base URL, sends headers and returns typed data", async () => {
    server.handle((_req, res) => send(res, 200, anna));
    const user = await api().users.get("u1");
    expect(user.createdAt).toBeInstanceOf(Date);
    expect(user.name).toBe("Anna");
    expect(server.requests[0]).toMatchObject({ method: "GET", url: "/v1/users/u1" });
    expect(server.requests[0]!.headers.authorization).toBe("Bearer test-token");
    expect(server.requests[0]!.headers.accept).toBe("application/json, application/problem+json");
  });

  it("puts query parameters into the URL and skips undefined ones", async () => {
    server.handle((_req, res) => send(res, 200, { items: [anna], nextCursor: null }));
    const page = await api().users.list();
    expect(page.items[0]!.email).toBe("anna@example.com");
    expect(server.requests[0]!.url).toBe("/v1/users?limit=20");
  });

  it("rejects a body that does not match the schema and does not retry it", async () => {
    server.handle((_req, res) => send(res, 200, { ...anna, email: "not-an-email" }));
    const error = await fail(api().users.get("u1"));
    expect(error).toBeInstanceOf(ApiError);
    expect(error).toMatchObject({ kind: "invalid-response", status: 200 });
    expect(server.requests).toHaveLength(1);
  });

  it("turns application/problem+json into ApiError and keeps extensions", async () => {
    const problem = {
      type: "https://example.com/probs/out-of-credit",
      title: "You do not have enough credit.",
      detail: "Your current balance is 30, but that costs 50.",
      instance: "/account/12345/msgs/abc",
      balance: 30,
      accounts: ["/account/12345", "/account/67890"],
    };
    server.handle((_req, res) => send(res, 403, problem, "application/problem+json"));
    const client = createApiClient({ baseUrl: server.baseUrl });
    const error = await fail(client.post("purchase", { schema: z.object({}), body: { item: 123456, quantity: 2 } }));
    expect(error).toBeInstanceOf(ApiError);
    expect(error).toMatchObject({ kind: "http", status: 403, problem });
    expect((error as ApiError).message).toBe("POST /v1/purchase answered 403: You do not have enough credit.");
    expect(server.requests[0]!.headers["content-type"]).toBe("application/json");
    expect(JSON.parse(server.requests[0]!.body)).toEqual({ item: 123456, quantity: 2 });
  });

  it("ignores problem members of the wrong type (RFC 9457, section 3.1)", async () => {
    server.handle((_req, res) => send(res, 404, { title: 42, status: "404", detail: "No user u9" }, "application/problem+json"));
    const error = (await fail(api().users.get("u9"))) as ApiError;
    expect(error.problem).toEqual({ type: "about:blank", detail: "No user u9" });
    expect(error.message).toBe("GET /v1/users/u9 answered 404: Not Found");
  });

  it("retries an idempotent GET after 503", async () => {
    server.handle((_req, res, n) => (n === 1 ? send(res, 503, "busy", "text/plain") : send(res, 200, anna)));
    expect((await api().users.get("u1")).id).toBe("u1");
    expect(server.requests).toHaveLength(2);
  });

  it("does not retry POST without an idempotency key", async () => {
    server.handle((_req, res) => send(res, 503, "busy", "text/plain"));
    const client = createApiClient({ baseUrl: server.baseUrl, retryDelayMs: 1 });
    const error = await fail(client.post("orders", { schema: z.object({}), body: {} }));
    expect(error).toMatchObject({ kind: "http", status: 503, problem: undefined });
    expect(server.requests).toHaveLength(1);
  });

  it("retries POST with an idempotency key and sends the same key every time", async () => {
    server.handle((_req, res, n) => (n === 1 ? send(res, 503, "busy", "text/plain") : send(res, 201, anna)));
    const user = await api().users.create({ name: "Anna", email: "anna@example.com" });
    expect(user.id).toBe("u1");
    const keys = server.requests.map((r) => r.headers["idempotency-key"]);
    expect(keys).toHaveLength(2);
    expect(keys[0]).toBeTruthy();
    expect(keys[1]).toBe(keys[0]);
  });

  it("returns undefined for 204 No Content", async () => {
    server.handle((_req, res) => void res.writeHead(204).end());
    await expect(api().users.remove("u1")).resolves.toBeUndefined();
  });

  it("gives up after timeoutMs with kind timeout", async () => {
    server.handle((_req, res) => setTimeout(() => send(res, 200, anna), 300));
    const client = createApiClient({ baseUrl: server.baseUrl, timeoutMs: 50, retries: 0 });
    const error = await fail(client.get("users/u1", { schema: z.unknown() }));
    expect(error).toMatchObject({ kind: "timeout" });
  });

  it("passes the caller's cancellation through as AbortError, without retries", async () => {
    server.handle((_req, res) => setTimeout(() => send(res, 200, anna), 300));
    for (const retries of [0, 2]) {
      const client = createApiClient({ baseUrl: server.baseUrl, retries });
      const controller = new AbortController();
      setTimeout(() => controller.abort(), 30);
      const error = await fail(client.get("users/u1", { schema: z.unknown(), signal: controller.signal }));
      expect(error).not.toBeInstanceOf(ApiError);
      expect((error as Error).name).toBe("AbortError");
    }
    expect(server.requests).toHaveLength(2); // one request per call
  });

  it("reports a non-JSON success body as invalid-response", async () => {
    server.handle((_req, res) => send(res, 200, "<html>maintenance</html>", "text/html"));
    const error = await fail(api().users.get("u1"));
    expect(error).toMatchObject({ kind: "invalid-response" });
  });

  it("maps a problem type to a user message", async () => {
    server.handle((_req, res) =>
      send(res, 409, { type: "https://api.example.com/problems/email-taken", title: "Email taken" }, "application/problem+json"),
    );
    const error = (await fail(api().users.create({ name: "Anna", email: "anna@example.com" }))) as ApiError;
    expect(errorMessage(error)).toBe("This email is already registered.");
  });

  it("reports a refused connection as a network error", async () => {
    const probe = createServer();
    await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
    const { port } = probe.address() as AddressInfo;
    await new Promise<void>((resolve) => probe.close(() => resolve()));
    const client = createApiClient({ baseUrl: `http://127.0.0.1:${port}`, retries: 1, retryDelayMs: 1 });
    const error = await fail(client.get("health", { schema: z.unknown() }));
    expect(error).toMatchObject({ kind: "network" });
  });
});
14 тестов: данные, схема, Problem Details, повторы, таймаут, отмена, сетевая ошибка
terminal
$ npx tsc -p .
$ npx vitest run --reporter=verbose

RUN  v5.0.3 …

 ✓ test/api-client.test.ts > typed API client > joins the base URL, sends headers and returns typed data 18ms
 ✓ test/api-client.test.ts > typed API client > puts query parameters into the URL and skips undefined ones 3ms
 ✓ test/api-client.test.ts > typed API client > rejects a body that does not match the schema and does not retry it 3ms
 ✓ test/api-client.test.ts > typed API client > turns application/problem+json into ApiError and keeps extensions 5ms
 ✓ test/api-client.test.ts > typed API client > ignores problem members of the wrong type (RFC 9457, section 3.1) 17ms
 ✓ test/api-client.test.ts > typed API client > retries an idempotent GET after 503 231ms
 ✓ test/api-client.test.ts > typed API client > does not retry POST without an idempotency key 16ms
 ✓ test/api-client.test.ts > typed API client > retries POST with an idempotency key and sends the same key every time 246ms
 ✓ test/api-client.test.ts > typed API client > returns undefined for 204 No Content 2ms
 ✓ test/api-client.test.ts > typed API client > gives up after timeoutMs with kind timeout 62ms
 ✓ test/api-client.test.ts > typed API client > passes the caller's cancellation through as AbortError, without retries 76ms
 ✓ test/api-client.test.ts > typed API client > reports a non-JSON success body as invalid-response 2ms
 ✓ test/api-client.test.ts > typed API client > maps a problem type to a user message 2ms
 ✓ test/api-client.test.ts > typed API client > reports a refused connection as a network error 13ms

 Test Files  1 passed (1)
      Tests  14 passed (14)
Реальный вывод: проверка типов без ошибок, 14 тестов (время у вас будет другим)

Тесты проверены «от обратного»: по одной ломали разбор Problem Details, ограничение повторов идемпотентными методами, проброс AbortError, игнорирование полей неверного типа, склейку URL, заголовок Idempotency-Key и пропуск undefined в query. Каждый раз падал хотя бы один профильный тест.

Ограничения

  • Таймаут задан на попытку. С retries: 2 и timeoutMs: 8000 худший случай — три попытки по 8 секунд плюс паузы. Общий срок операции добавляется сигналом AbortSignal.timeout() в signal вызова или обёрткой из урока о внешних API
  • Тело запроса — только JSON. Для FormData, загрузки файлов и потоковых ответов нужен отдельный метод: Content-Type для них ставит сам браузер или runtime
  • Пустым считается только ответ 204. Если API отдаёт 200 без тела, такой ответ попадёт в invalid-response — договоритесь с бэкендом или расширьте условие
  • Проверка схемой стоит времени на больших списках. Если ответ на тысячи записей приходит часто, проверяйте его выборочно или только в разработке — осознанно, а не по умолчанию
  • Zod добавляет код в бандл фронтенда. Для критичных к размеру страниц есть Zod Mini — вариант с тем же функционалом и функциональным API, который лучше поддаётся tree-shaking
  • Если у API есть контракт OpenAPI, схемы и типы удобнее генерировать из него, а рецепт использовать как транспортный слой

Связанные материалы и источники

  • «Внешние API: таймауты, повторы, лимиты и деградация» — полноценные повторы: jitter, Retry-After, общий срок, circuit breaker
  • «Проектирование API: ресурсы, версии, ошибки, пагинация» — как сервер должен отдавать Problem Details и поддерживать Idempotency-Key
  • «Архитектура фронтенда: структура проекта, слои и границы» — где в проекте живёт работа с API
  • «Асинхронный JavaScript: промисы, async/await и fetch» — основы fetch, AbortController и AbortSignal.timeout

Утверждения о fetch, AbortSignal, Zod и RFC сверены с официальной документацией на дату в шапке; ссылки — ниже.

Как проверено2 · пройденосверено 12 октября 2026 г.
  • запущено, работает

    tsconfig.json, src/api-client.ts, src/users.ts, src/api.ts, src/error-message.ts, test/server.ts, test/api-client.test.ts

    временный проект scratchpad/kb/verify/recept-api-klient-typescript: tsc -p . (TypeScript 7.0.2, strict, types node) без ошибок; tsc -p tsconfig.dom.json (src, lib es2024+dom, без типов Node) без ошибок; vitest run — 14/14 против локального node:http сервера, три прогона стабильно зелёные; Node.js 24.18.0, zod 4.6.6, Vitest 5.0.3, @types/node 24.19.2

  • запущено, работает

    мутационная проверка тестов

    по одной мутации: без разбора problem+json (2 теста упали), повтор любых методов (1), без проброса AbortError (1 — после усиления теста на retries 0), без .catch у полей Problem Details (1), наивная склейка URL (4), без заголовка Idempotency-Key (1), undefined в query (1); после отката 14/14

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.