Типизированный API-клиент — это тонкая обёртка над fetch, через которую проходят все запросы проекта: она собирает URL от базового адреса, ставит общие заголовки, ограничивает время ожидания, проверяет ответ схемой Zod и превращает любые сбои в один класс ApiError с разобранным Problem Details. Рецепт даёт готовый файл api-client.ts около 180 строк, пример модуля API и тесты против локального HTTP-сервера.
После материала вы сможете
- подключить один клиент над
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 г.
Содержание
Задача: разрозненные 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
npm init -y
npm pkg set type=module
npm install zod@4
npm install -D typescript @types/node@24 vitest{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test"]
}strict: trueimport { 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>;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 и сообщения об ошибках
Схемы рядом с вызовами
Для каждого ресурса — модуль со схемами Zod и функциями:
users.get,users.list. Тип выводится из схемы черезz.output, отдельныйinterfaceне нужен.Один клиент на API
Точка сборки создаёт клиент с базовым URL из конфигурации и функцией получения токена и отдаёт наружу уже типизированные модули.
Компоненты зовут модули
Интерфейс вызывает
api.users.get(id, signal)и получаетUserсcreatedAtтипаDate— преобразование сделала схема.Ошибки — в тексты интерфейса
catchпроверяетinstanceof ApiErrorи выбирает сообщение по виду ошибки, коду иtype.AbortErrorпользователю не показывается.
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 получает ключ идемпотентности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) };
}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, заголовки, тело и число попыток.
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));
}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" });
});
});$ 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)Тесты проверены «от обратного»: по одной ломали разбор 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.