HTTP-статусы и тело ответа: как сделать ошибки предсказуемыми для клиентов
Построим согласованный формат ошибок: коды, message, details и корреляционные идентификаторы. Разберём, как не отдавать разные структуры в зависимости от маршрута.
Содержание
HTTP-статусы и тело ответа: как сделать ошибки предсказуемыми для клиентов
Ошибки — это тот участок API, который чаще всего «плывёт» со временем. Сегодня один маршрут отдаёт {error: "..."}, завтра другой — {message: "...", code: ...}, а ещё через месяц где-то появляется HTML-страница или пустой ответ с 500. Клиенту в таких условиях приходится угадывать структуру: где-то распознавать по заголовкам, где-то по форме JSON, где-то по тексту. В результате растут стоимость поддержки, количество обработчиков «для особых случаев» и вероятность того, что критические ошибки будут проигнорированы клиентом.
Цель этой статьи — показать, как построить согласованный формат ошибок для HTTP API: выбрать корректные статусы, определить тело ответа (например, message и details), добавить корреляционные идентификаторы для трассировки и, главное, добиться предсказуемости: один и тот же контракт ошибок должен возвращаться независимо от маршрута. Это не теория «как правильно», а практический подход, который обычно внедряют после того, как API начинает обрастать количеством endpoints и разрозненными обработчиками.
Почему клиенты «не любят» ошибки (и где именно всё ломается)
Реальная боль клиентов обычно не в самом факте ошибки — а в непредсказуемости. Непредсказуемость проявляется в трёх измерениях:
-
HTTP-статус не соответствует телу
Например, статус400вместе с телом, где «ошибка» выглядит как системная (traceIdотсутствует, поля несогласованы), или наоборот. Клиентские библиотеки часто строятся вокруг статусов:4xxобрабатывают как «вина клиента»,5xx— как «вина сервера». -
Слишком много форматов ошибок
Часто встречается ситуация: валидация отправляет одно, бизнес-правила — другое, исключения фреймворка — третье, а неожиданные ошибки могут возвращать пустое тело или HTML. -
Отсутствие корреляции между ответом и логами
Клиенту нужно сообщить, что именно пошло не так. Разработчикам — быстро найти инцидент в логах. Без идентификатора это превращается в обмен сообщениями вида «у нас на фронте упало, где-то, кажется, был 500».
Чтобы ошибки стали «потребляемыми», контракт должен быть стабильным: структура ответа и смысл полей неизменны, меняются только значения.
Базовый принцип: ошибки — это не исключения, а публичный контракт
Внутри сервера могут быть любые исключения, но наружу API обязано отдавать единый формат. Это означает:
- один слой отвечает за маппинг исключений/ошибок на HTTP-статусы;
- второй слой формирует тело по единой схеме;
- третий слой обеспечивает, чтобы независимо от маршрута контракт не разъезжался.
На практике это часто реализуют через централизованный middleware/handler (в зависимости от стека), который перехватывает исключения, собирает контекст и возвращает JSON в заранее определённом формате.
Статусы: как выбрать код, который клиент сможет интерпретировать
HTTP-статусы — это соглашение между клиентом и сервером. Они не обязаны быть идеальными на 100%, но должны быть последовательными и объясняющими поведение.
400 vs 422: валидация и семантика
400 Bad Request— запрос синтаксически неверен: JSON не парсится, отсутствуют обязательные поля, неверный формат заголовка и т. п.422 Unprocessable Entity— запрос синтаксически корректен, но семантически не проходит валидацию бизнес-правил/формата данных (например, полеemailне соответствует формату, длина строки вне диапазона, enum не совпадает).
В некоторых системах 422 не используют — тогда везде применяют 400. Но важно: выбрав стратегию, придерживаться её.
Валидация обычно возвращает структурированные детали: список полей и сообщений.
401 и 403: аутентификация и авторизация
401 Unauthorized— клиент не аутентифицировался (нет токена, токен неверный/просроченный). Часто добавляютWWW-Authenticate.403 Forbidden— клиент аутентифицирован, но не имеет прав. Тело ответа может помочь понять, какие действия недоступны (но не раскрывать лишнее).
404 и «скрытие существования»
404 Not Found— ресурс не найден.
Иногда системы сознательно возвращают 404 вместо 403, чтобы «не светить» существование ресурса. Это влияет на контракт ошибок: клиенту важно понимать, что именно означает 404 в вашем домене.
409: конфликты
409 Conflict— конфликт состояния: например, попытка изменить ресурс, который изменился параллельно; попытка создать дубликат, нарушающий уникальность.
422/409/400 при доменных ошибках
Доменные ошибки часто сложно разложить строго по стандарту, но правило простое:
- если ошибка связана с форматом/валидацией ввода — ближе к
400/422; - если ошибка — про текущее состояние/ограничения — ближе к
409или422(зависит от смысла).
500: неожиданные ошибки
500 Internal Server Error— всё, что не удалось классифицировать.
Важно: в ответе не отдавать stack trace, SQL, подробные внутренние сообщения. Клиенту достаточноmessage+correlationId, а подробности — в логах.
Единый формат ошибки: коды, message, details и correlationId
Один из самых практичных вариантов — сделать тело ответа предсказуемым и машиночитаемым.
Ниже — пример контракта:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"path": "user.email",
"message": "Invalid email format",
"type": "FORMAT"
}
],
"correlationId": "01J6Y7K9Q3X2A1B4C5D6E7F8"
}
}
Разберём поля:
code: стабильный идентификатор ошибки для логики клиента
- Это не HTTP-статус. Это доменный/контрактный код, который не должен меняться от версии к версии без причины.
- Клиент использует
codeдля отображения локализованных сообщений, подсказок или для ветвления логики.
Примеры кодов:
VALIDATION_ERRORAUTHENTICATION_REQUIREDAUTHORIZATION_DENIEDRESOURCE_NOT_FOUNDCONFLICTINTERNAL_ERROR
message: человекочитаемое объяснение без раскрытия деталей
message— для пользователя/администратора, но в API он также полезен как быстрый сигнал клиенту.- Сообщение должно быть корректным, но не подробным: не показывать внутренние названия таблиц, запросы, stack trace.
details: расширение для валидации и множественных проблем
- Поле
detailsкритично для ошибок валидации: клиенту нужно понять, какие поля и почему не прошли. - Структуру
detailsможно расширять, но важно сохранять общий подход:details— массив или объект с понятной схемой, а не произвольный JSON.
Частая ошибка — отдавать details как строку или как объект разной структуры в зависимости от маршрута. Это возвращает нас к исходной проблеме.
correlationId: ключ для связки ответа и логов
- Идентификатор, который сервер генерирует для конкретного инцидента.
- Его клиент может включить в тикет, а поддержка/разработчики — использовать для поиска в логах.
- Хорошая практика — добавлять корреляционный ID не только в тело, но и в заголовок (например,
X-Correlation-Id), чтобы клиент мог логировать его даже при невалидном JSON.
Как не отдавать разные структуры в зависимости от маршрута
Ключевая мысль: формат ошибки должен быть единым для всего API, а различаться могут только значения и количество элементов в details.
Что обычно ломается на практике
-
Валидация сделана фреймворком, бизнес-ошибки — вручную
Валидация отдаёт{message: ..., errors: ...}, а бизнес —{error: ...}. -
Catch блок на одном маршруте
Где-то естьtry/catch, а где-то — нет. В результате для одинаковых категорий ошибок появляются разные ответы. -
Разные middleware для разных префиксов
Например,/v1обрабатывается одним слоем,/admin— другим, и они возвращают разный формат. -
Непредусмотренный fallback
Когда исключение не попало ни в одну категорию, сервер возвращает стандартную страницу или пустой ответ — и клиент ломается.
Решение: централизованный error mapper
Суть: определить единую структуру и один точный путь формирования ответа.
Ниже пример (условный) на Node.js/TypeScript в стиле Express-обработчика. Он показывает принципы; конкретная реализация зависит от фреймворка.
type ErrorDetail = {
path?: string;
message: string;
type?: string;
};
type ApiErrorBody = {
error: {
code: string;
message: string;
details?: ErrorDetail[];
correlationId: string;
};
};
function correlationIdFromReq(req: any): string {
// пример: если клиент прислал X-Correlation-Id — используем, иначе генерируем
const incoming = req.headers["x-correlation-id"];
return typeof incoming === "string" && incoming.length > 0
? incoming
: crypto.randomUUID();
}
function toApiErrorBody(params: {
code: string;
message: string;
details?: ErrorDetail[];
correlationId: string;
}): ApiErrorBody {
return {
error: {
code: params.code,
message: params.message,
...(params.details ? { details: params.details } : {}),
correlationId: params.correlationId,
},
};
}
// Пример классификации
function classifyError(err: any) {
// Здесь вы маппите ваши доменные типы/исключения на контрактные коды и статусы
if (err?.name === "ValidationError") {
return {
httpStatus: 422,
code: "VALIDATION_ERROR",
message: "Request validation failed",
details: err.details as ErrorDetail[],
};
}
if (err?.name === "UnauthorizedError") {
return {
httpStatus: 401,
code: "AUTHENTICATION_REQUIRED",
message: "Authentication is required",
};
}
if (err?.name === "ForbiddenError") {
return {
httpStatus: 403,
code: "AUTHORIZATION_DENIED",
message: "Access is denied",
};
}
if (err?.name === "NotFoundError") {
return {
httpStatus: 404,
code: "RESOURCE_NOT_FOUND",
message: "Resource not found",
};
}
if (err?.name === "ConflictError") {
return {
httpStatus: 409,
code: "CONFLICT",
message: "Request conflicts with current state",
};
}
return {
httpStatus: 500,
code: "INTERNAL_ERROR",
message: "Internal server error",
};
}
// Централизованный обработчик ошибок
function errorMiddleware(err: any, req: any, res: any, _next: any) {
const correlationId = correlationIdFromReq(req);
const classified = classifyError(err);
// Логи — с correlationId, но без раскрытия деталей пользователю
// logger.error({ err, correlationId, route: req.originalUrl });
const body = toApiErrorBody({
code: classified.code,
message: classified.message,
details: classified.details,
correlationId,
});
res.setHeader("X-Correlation-Id", correlationId);
res.status(classified.httpStatus).json(body);
}
Что важно здесь:
errorMiddlewareформирует всегда один и тот же формат.- Даже для 500 используется тот же контейнер
{"error": {...}}. correlationIdприсутствует всегда.- Детали (
details) добавляются только там, где они есть, но структура не меняется.
Контроль: договор и автоматические тесты
Чтобы не «сломать» формат случайно, стоит добавить контрактные тесты:
- один тест на то, что для набора ошибок ответ всегда содержит
error.code,error.message,error.correlationId; - тесты на то, что для валидации
details— массив, а каждый элемент имеетpath/message/type(или вашу схему).
Такой подход ловит проблему раньше, чем клиент начнёт парсить разные структуры.
Корреляция: как сделать correlationId полезным
Корреляционный идентификатор — это не просто красивый UUID в ответе. Он должен участвовать в цепочке:
-
Приоритет входящего заголовка
Если клиент отправляетX-Correlation-Id, сервер может использовать его, чтобы весь процесс был связан единым ID. Если не отправляет — сервер генерирует. -
Постоянство на уровне запроса
Все логи внутри обработки одного запроса должны включать этот ID. -
Интеграция с мониторингом
В идеале корреляция должна попадать в tracing (OpenTelemetry) и Sentry/Datadog. Тогда вы соединяете API-ошибку, тайминги и трассы. -
Не путать с requestId
Иногда системы уже имеютrequestIdдля внутреннего use-case. Можно иметь два идентификатора, но для клиента обычно лучше один «публичный» и стабильный:correlationId.
Пример полного контракта: единые ошибки для типовых сценариев
Рассмотрим несколько ответов, показывающих согласованность.
Неверный формат тела (400/422)
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
X-Correlation-Id: 01J6Y7K9Q3X2A1B4C5D6E7F8
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "path": "user.email", "message": "Invalid email format", "type": "FORMAT" },
{ "path": "user.age", "message": "Must be greater than or equal to 18", "type": "MIN" }
],
"correlationId": "01J6Y7K9Q3X2A1B4C5D6E7F8"
}
}
Нет прав (403)
HTTP/1.1 403 Forbidden
Content-Type: application/json
X-Correlation-Id: 01J6Y7K9Q3X2A1B4C5D6E7F8
{
"error": {
"code": "AUTHORIZATION_DENIED",
"message": "Access is denied",
"correlationId": "01J6Y7K9Q3X2A1B4C5D6E7F8"
}
}
Не найден (404)
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Resource not found",
"correlationId": "01J6Y7K9Q3X2A1B4C5D6E7F8"
}
}
Внутренняя ошибка (500)
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
X-Correlation-Id: 01J6Y7K9Q3X2A1B4C5D6E7F8
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Internal server error",
"correlationId": "01J6Y7K9Q3X2A1B4C5D6E7F8"
}
}
Обратите внимание: клиент всегда получает один и тот же каркас error. Это снижает стоимость интеграции и уменьшает количество ветвлений в клиентском коде.
Подводные камни: что часто забывают
1) Не отдавайте JSON там, где может быть HTML
Если у вас есть плагины/шлюзы, которые иногда отдают HTML при ошибках (например, прокси, gateway, reverse proxy), убедитесь, что на уровне вашего API ошибки форматируются одинаково. Иначе клиент для некоторых 5xx может получать не JSON.
2) Не меняйте схему details без версии контракта
Если details в одном месте массив объектов с path, а в другом — объект с разными ключами, клиенты начнут писать парсеры под маршрут. Даже если сейчас у вас всего несколько endpoints — мигрируйте к единой форме.
3) Не используйте текст как код
Текст message со временем меняется: перевод, улучшения формулировок, форматирование. Ветка логики клиента должна опираться на code, а не на message.
4) Следите за консистентностью статусов
Если валидационная ошибка иногда отдаёт 400, иногда 422, клиентские обработчики будут усложняться. Стандартизируйте стратегию и закрепите её тестами.
5) Скрывайте внутренние детали в 500
Не отправляйте пользователю:
- stack trace,
- SQL,
- сырые сообщения из исключений,
- персональные данные из
Комментарии
Пока нет комментариев