Пошук уроків, статей та іншого контенту
Побудуйте єдину стратегію обробки винятків, помилок валідації та HTTP-статусів в API.
Під час роботи API можуть виникати різні типи помилок:
клієнт надіслав некоректний JSON;
дані не пройшли валідацію;
ресурс не знайдено;
операція конфліктує з поточним станом даних;
сталася неочікувана помилка на сервері.
Якщо кожен endpoint повертає помилки у власному форматі, клієнтський код стає складним. Замість цього варто визначити єдину стратегію:
використовувати передбачувані HTTP-статуси;
повертати однакову структуру JSON;
розділяти очікувані помилки та неочікувані винятки;
не розкривати клієнту внутрішні деталі сервера.
У цьому уроці використовується App Router і Route Handlers у Next.js.
Наприклад, кожна помилка API може мати такий формат:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Перевірте дані запиту",
"details": {
"fields": {
"email": "Некоректний email"
}
}
}
}Основні поля:
code — стабільний машинний код помилки;
message — зрозуміле повідомлення;
details — додаткова інформація, наприклад помилки окремих полів.
Клієнтський застосунок може перевіряти code, а не аналізувати текст message.
| Ситуація | Статус | |---|---:| | Некоректний JSON або параметри запиту | 400 Bad Request | | Помилка валідації полів | 422 Unprocessable Entity | | Потрібна автентифікація | 401 Unauthorized | | Недостатньо прав | 403 Forbidden | | Ресурс не знайдено | 404 Not Found | | Конфлікт, наприклад дубльований email | 409 Conflict | | Неочікувана помилка сервера | 500 Internal Server Error |
Статус 400 зазвичай описує неправильний формат самого запиту. Статус 422 доречний, коли формат запиту правильний, але значення не відповідають правилам бізнес-логіки або валідації.
Створимо окремий клас ApiError. Він дозволить явно повідомляти endpoint, що помилка є очікуваною і має конкретний HTTP-статус.
// lib/api-error.ts
export type ApiErrorCode =
| "BAD_REQUEST"
| "VALIDATION_ERROR"
| "NOT_FOUND"
| "CONFLICT";
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: ApiErrorCode,
message: string,
public readonly details?: unknown,
) {
super(message);
this.name = "ApiError";
}
}Наприклад, помилку валідації можна створити так:
throw new ApiError(
422,
"VALIDATION_ERROR",
"Перевірте дані запиту",
{
fields: {
email: "Некоректний email",
},
},
);Окрема функція перетворює виняток на HTTP-відповідь.
// lib/api-error-response.ts
import { NextResponse } from "next/server";
import { ApiError } from "./api-error";
export function toErrorResponse(error: unknown) {
if (error instanceof ApiError) {
return NextResponse.json(
{
error: {
code: error.code,
message: error.message,
...(error.details !== undefined
? { details: error.details }
: {}),
},
},
{ status: error.status },
);
}
// Повні деталі помилки потрібно записувати в серверні логи,
// але не повертати клієнту.
console.error("Unexpected API error:", error);
return NextResponse.json(
{
error: {
code: "INTERNAL_ERROR",
message: "Внутрішня помилка сервера",
},
},
{ status: 500 },
);
}Тут важливе розділення:
ApiError — очікувана помилка, яку можна безпечно повернути клієнту;
будь-яке інше значення — неочікувана помилка, яку потрібно приховати за загальним повідомленням.
Не варто повертати клієнту error.message для невідомих винятків. Так можна випадково розкрити SQL-запит, шлях до файлу, секретний ключ або внутрішню структуру сервера.
Розглянемо endpoint POST /api/users, який:
читає JSON;
перевіряє поля name та email;
повертає 409, якщо email вже використовується;
обробляє очікувані й неочікувані помилки єдиним способом.
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { ApiError } from "@/lib/api-error";
import { toErrorResponse } from "@/lib/api-error-response";
type CreateUserBody = {
name: string;
email: string;
};
const registeredEmails = new Set<string>();
async function readJson(request: Request): Promise<unknown> {
try {
return await request.json();
} catch {
throw new ApiError(
400,
"BAD_REQUEST",
"Тіло запиту має містити коректний JSON",
);
}
}
function validateCreateUserBody(body: unknown): CreateUserBody {
const fields: Record<string, string> = {};
if (typeof body !== "object" || body === null) {
throw new ApiError(
422,
"VALIDATION_ERROR",
"Тіло запиту має бути об'єктом",
);
}
const data = body as Record<string, unknown>;
if (typeof data.name !== "string" || data.name.trim().length < 2) {
fields.name = "Ім'я має містити щонайменше 2 символи";
}
if (
typeof data.email !== "string" ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)
) {
fields.email = "Некоректний email";
}
if (Object.keys(fields).length > 0) {
throw new ApiError(
422,
"VALIDATION_ERROR",
"Перевірте дані запиту",
{ fields },
);
}
return {
name: data.name.trim(),
email: data.email.toLowerCase().trim(),
};
}
export async function POST(request: Request) {
try {
const body = await readJson(request);
const user = validateCreateUserBody(body);
if (registeredEmails.has(user.email)) {
throw new ApiError(
409,
"CONFLICT",
"Користувач із таким email уже існує",
);
}
registeredEmails.add(user.email);
return NextResponse.json(
{
data: {
id: crypto.randomUUID(),
name: user.name,
email: user.email,
},
},
{ status: 201 },
);
} catch (error: unknown) {
return toErrorResponse(error);
}
}У реальному застосунку registeredEmails буде замінено на запит до бази даних. Стратегія обробки помилок при цьому залишиться такою самою: помилки рівня сервісу перетворюються на ApiError, а Route Handler повертає уніфіковану відповідь.
Необов’язково виконувати всю бізнес-логіку безпосередньо в Route Handler. Частину можна винести в сервіс.
Сервіс може повідомляти про очікувані ситуації через ApiError:
// lib/users-service.ts
import { ApiError } from "./api-error";
type CreateUserInput = {
name: string;
email: string;
};
export async function createUser(input: CreateUserInput) {
// Тут зазвичай виконується запит до бази даних.
const userAlreadyExists = false;
if (userAlreadyExists) {
throw new ApiError(
409,
"CONFLICT",
"Користувач із таким email уже існує",
);
}
return {
id: crypto.randomUUID(),
...input,
};
}Route Handler не повинен перевіряти кожну можливу помилку окремо:
try {
// Виклик сервісу
} catch (error) {
if (error instanceof SomeDatabaseError) {
// ...
} else if (error instanceof AnotherError) {
// ...
}
}Такий підхід швидко створює дублювання. Краще перетворювати відомі помилки на ApiError на межі між інфраструктурою та бізнес-логікою, а в endpoint залишати один загальний catch.
Помилка валідації повинна містити достатньо даних, щоб клієнт міг показати повідомлення біля відповідного поля.
Приклад відповіді:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Перевірте дані запиту",
"details": {
"fields": {
"name": "Ім'я має містити щонайменше 2 символи",
"email": "Некоректний email"
}
}
}
}Не варто повертати в details конфіденційні дані, наприклад:
SQL-запити;
значення паролів;
токени;
повні об'єкти користувачів;
внутрішні stack trace.
Валідація повинна виконуватися на сервері навіть тоді, коли такі самі правила вже є на клієнті. Клієнтська валідація покращує взаємодію з користувачем, але не є механізмом безпеки.
Виклик request.json() може завершитися помилкою, якщо тіло запиту:
порожнє;
має пошкоджений JSON;
не відповідає заявленому формату.
Тому не слід викликати його без обробки:
const body = await request.json();Якщо виняток залишиться необробленим, клієнт може отримати непередбачувану відповідь. Замість цього потрібно перетворити його на 400 Bad Request, як у функції readJson.
Заголовок Content-Type: application/json також варто перевіряти, якщо endpoint приймає лише JSON:
const contentType = request.headers.get("content-type");
if (!contentType?.includes("application/json")) {
throw new ApiError(
400,
"BAD_REQUEST",
"Очікується Content-Type: application/json",
);
}Таку перевірку можна додати перед викликом request.json().
Для невідомих помилок використовуйте стабільну відповідь:
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Внутрішня помилка сервера"
}
}HTTP-статус у цьому випадку — 500.
На сервері потрібно зберегти повну інформацію для діагностики:
stack trace;
ідентифікатор запиту;
endpoint;
HTTP-метод;
час виникнення;
контекст операції без секретних даних.
Але відповідь API не повинна залежати від того, яка саме внутрішня помилка сталася. Для клієнта всі неочікувані помилки мають однаковий безпечний формат.
Клієнтський код може спочатку перевірити HTTP-статус, а потім прочитати стандартне поле error.
type ApiErrorResponse = {
error: {
code: string;
message: string;
details?: {
fields?: Record<string, string>;
};
};
};
async function createUser(name: string, email: string) {
const response = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name, email }),
});
const payload = await response.json();
if (!response.ok) {
const errorPayload = payload as ApiErrorResponse;
if (errorPayload.error.code === "VALIDATION_ERROR") {
return {
ok: false as const,
fields: errorPayload.error.details?.fields ?? {},
};
}
if (errorPayload.error.code === "CONFLICT") {
return {
ok: false as const,
message: errorPayload.error.message,
};
}
return {
ok: false as const,
message: "Не вдалося створити користувача",
};
}
return {
ok: true as const,
user: payload.data,
};
}Клієнт не повинен покладатися лише на message, оскільки текст може змінитися або бути локалізований. Для логіки використовуйте стабільний code, а message показуйте користувачу.
200 для помилкиПогано:
return NextResponse.json({
success: false,
message: "Користувача не знайдено",
});У такому випадку клієнт і моніторинг можуть сприйняти відповідь як успішну. Краще повернути 404:
return NextResponse.json(
{
error: {
code: "NOT_FOUND",
message: "Користувача не знайдено",
},
},
{ status: 404 },
);Якщо один endpoint повертає { message: "..." }, інший — { errorMessage: "..." }, а третій — { errors: [] }, клієнту доводиться містити спеціальну логіку для кожного маршруту.
Використовуйте один формат для всього API.
error.message невідомого виняткуПогано:
catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 },
);
}Повідомлення може містити внутрішні деталі. Для невідомого винятку повертайте загальне повідомлення, а повну помилку записуйте в серверний лог.
catchПогано:
try {
await saveUser();
} catch {
return NextResponse.json(
{ error: "Помилка" },
{ status: 500 },
);
}Такий код приховує важливу інформацію під час діагностики. Централізований обробник має логувати неочікувані помилки.
Якщо JSON синтаксично коректний, але поле email має неправильне значення, це не серверна помилка 500. Використовуйте 422.
Визначте єдиний формат помилки для всіх endpoint.
Використовуйте окремі коди помилок, стабільні для клієнта.
Для очікуваних ситуацій створюйте власний клас ApiError.
Перетворюйте помилки парсингу JSON на 400.
Повертайте помилки полів через 422 і details.fields.
Для конфліктів стану використовуйте 409.
Не розкривайте stack trace та внутрішні повідомлення клієнту.
Логуйте неочікувані винятки на сервері.
У Route Handler використовуйте один централізований catch.
Перевіряйте не лише тіло відповіді, а й HTTP-статус.
Єдина стратегія обробки помилок API складається з трьох частин:
Очікувані помилки описуються через ApiError із конкретним статусом і кодом.
Помилки валідації повертають структуровані дані про проблемні поля.
Неочікувані винятки логуються на сервері, але клієнт отримує безпечну відповідь із 500.
Такий підхід робить Route Handlers передбачуваними, спрощує клієнтський код і допомагає підтримувати однакову поведінку всього API.