Стиль ошибок API: что вернуть в 400 и как это документировать
API возвращает 400 Bad Request с телом {"error":"invalid"}. Клиент видит это и не понимает: что именно невалидно, какое поле, что показать пользователю в форме. Через неделю таких ошибок в саппорт прилетает «у вас сайт сломан» — и фронтенд-разработчик идёт читать исходники бэкенда, потому что в доках на эндпоинт описан только happy path.
Ошибки в API — это половина контракта. Если их не описать, интеграция превращается в реверс-инжиниринг. Разберу, какой формат ошибок реально работает в продакшене, что писать в теле 4xx ответов и как это документировать так, чтобы клиентский разработчик не переспрашивал.
Почему «error: string» не работает
Самая частая схема, которую вижу в чужих API:
{
"error": "Validation failed"
}
Проблем сразу несколько. Клиент не знает, какое поле сломалось — нельзя подсветить input в форме. Текст приходит уже локализованный (или, наоборот, на английском), и фронтенд не может его перевести. Если ошибок несколько, они склеены в одну строку. И главное — структура ответа на ошибку отличается от структуры ответа на успех, поэтому клиент пишет два разных парсера.
Второй частый вариант — отдавать HTML или plain text от веб-сервера. Это случается, когда middleware обрабатывает 500 раньше, чем контроллер. JSON-клиент падает на парсинге, в логах появляется «Unexpected token < in JSON at position 0», и команда тратит день, чтобы понять, что дело в reverse proxy.
Что должно быть в теле ошибки
Минимум, который покрывает 90% случаев:
- code — стабильный машинный идентификатор. Не меняется между релизами. Клиент использует его для логики (показать модалку «Пополните баланс» или редирект на логин).
- message — человекочитаемое описание. На языке API (обычно английский), без подстановок пользовательских данных.
- field — для ошибок валидации: имя поля. Через точку для вложенных:
user.address.zip. - details — опциональный объект с контекстом. Например,
{"min": 8, "actual": 4}для ошибки длины пароля.
Было:
{
"error": "Password too short"
}
Стало:
{
"error": {
"code": "password_too_short",
"message": "Password must be at least 8 characters",
"field": "password",
"details": { "min": 8, "actual": 4 }
}
}
Теперь фронтенд может: показать сообщение из message, подсветить поле password, локализовать ошибку через code (свой словарь password_too_short → «Пароль слишком короткий»), и при желании показать «нужно ещё 4 символа», вычитав details.
RFC 7807: problem+json
Для публичных API стоит посмотреть на RFC 7807 — стандарт «Problem Details for HTTP APIs». Содержит готовую схему: type (URI с описанием класса ошибки), title, status, detail, instance. Content-Type — application/problem+json.
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 402,
"detail": "Account 12345 has balance 50, requested 100",
"instance": "/transfers/abc-123",
"balance": 50,
"requested": 100
}
Плюс — у клиентов есть библиотеки под этот формат. Минус — поле для имени невалидного поля в стандарте не определено, его дописывают как расширение. Если делаешь публичный B2B API с клиентами на разных языках, RFC 7807 — разумный выбор. Для внутреннего API проще обойтись своей схемой, главное — чтобы она была одна на все эндпоинты.
Несколько ошибок одновременно
Форма с десятью полями. Юзер заполнил пять с ошибками. Если возвращать первую найденную, он будет тыкать «отправить» пять раз. Поэтому для валидации — массив:
{
"error": {
"code": "validation_failed",
"message": "Request validation failed",
"errors": [
{
"code": "required",
"field": "email",
"message": "Email is required"
},
{
"code": "out_of_range",
"field": "age",
"message": "Age must be between 18 and 120",
"details": { "min": 18, "max": 120, "actual": 5 }
}
]
}
}
Внешняя обёртка с общим code остаётся — клиент по ней понимает, что это валидация, и идёт парсить errors. Если ошибка одна и не валидационная (нет прав, не найдено), массива нет.
Какой статус-код ставить
Список, которым пользуюсь сама и которому учу команды:
- 400 — тело запроса невалидное: не парсится JSON, не сходится схема, нарушение бизнес-правил при валидации входа.
- 401 — нет токена или токен невалидный/просрочен. Клиент должен пойти логиниться.
- 403 — токен есть, юзер аутентифицирован, но прав на это действие нет. Клиент логиниться не идёт.
- 404 — ресурса нет. Не используй для «нет прав» — это маскировка, путает клиентов и ломает кеши.
- 409 — конфликт состояния: дубликат email при регистрации, изменение версии (optimistic locking).
- 422 — синтаксис верный, но семантика бизнеса нарушена. Споры «400 vs 422» бесконечны, выбери один и придерживайся. Я обычно беру 400 для всего, кроме явных доменных конфликтов.
- 429 — rate limit. Обязательно с заголовком
Retry-After. - 500 — у тебя баг. Не клади сюда «не нашли пользователя».
Главное правило: один и тот же класс ошибки всегда возвращает один и тот же код. Если «email занят» иногда 409, иногда 400, иногда 422 — клиент не сможет написать общий обработчик.
Идентификатор запроса в каждой ошибке
На каждый 5xx и часть 4xx добавляй request_id. Это та же строка, что попадает в логи бэкенда. Когда клиент пишет в саппорт «не работает оплата», он присылает request_id, и инженер за пять секунд находит трейс в Loki/Sentry/чём-то ещё.
{
"error": {
"code": "internal_error",
"message": "Something went wrong",
"request_id": "01HZK9NVGM3T6QHFBJ8R4Y2C7P"
}
}
То же самое лучше дублировать в заголовок X-Request-Id — тогда CDN и прокси его сохранят, даже если тело по какой-то причине не дойдёт.
Документация: схема + примеры + список кодов
Описание ошибок в OpenAPI часто выглядит так:
responses:
'400':
description: Bad Request
Это не документация. Это галочка. Клиент по такой странице не сможет ни написать обработку, ни локализовать сообщения. Минимум, что нужно:
components:
schemas:
Error:
type: object
required: [error]
properties:
error:
type: object
required: [code, message]
properties:
code:
type: string
description: Stable machine-readable error code
message:
type: string
description: Human-readable message in English
field:
type: string
description: Field name for validation errors
details:
type: object
additionalProperties: true
request_id:
type: string
paths:
/users:
post:
responses:
'400':
description: Validation failed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
emailTaken:
summary: Email already registered
value:
error:
code: email_taken
message: Email is already registered
field: email
passwordTooShort:
summary: Password too short
value:
error:
code: password_too_short
message: Password must be at least 8 characters
field: password
details: { min: 8, actual: 4 }
Дальше — отдельная страница «Error codes» в доках, где собраны все возможные значения code с пояснением и рекомендацией для клиента. Эту страницу можно генерировать из OpenAPI, если в каждом примере проставлен code: парсишь examples, собираешь уникальные значения, рендеришь Markdown.
Пример строки в такой таблице:
email_taken— 409 — пользователь с таким email уже зарегистрирован. Клиенту: предложить вход или восстановление пароля.password_too_short— 400 — пароль корочеdetails.minсимволов. Клиенту: показать ошибку под полем.token_expired— 401 — JWT просрочен. Клиенту: обновить токен через refresh или редирект на логин.
Чего не делать
Не подставлять пользовательский ввод в message без экранирования. "User 'admin'); DROP TABLE...' not found" — известный способ получить XSS, если клиент рендерит сообщение как HTML. Подставляй данные в details, а message держи статичной.
Не менять code между минорными версиями. Если переименовываешь — старое значение оставь как алиас минимум на полгода, а в changelog напиши явно. Я видела, как переименование not_found в resource_not_found сломало мобильное приложение, потому что обработчик был завязан на точное совпадение строки.
Не возвращать stacktrace в проде. Никогда. Даже под флагом «debug=true» в проде. Стектрейс — это карта внутренней структуры приложения, и попадание её в публичный API — это утечка. Для отладки есть request_id и серверные логи.
Не использовать 200 OK с {"success": false}. Это любимая схема старого PHP-API. Кеши, прокси и мониторинги считают такой ответ успехом, метрики ошибок ломаются, retry-логика не срабатывает. HTTP-семантика существует — пользуйся ей.
Чек-лист, что проверить в своём API
- Тело ошибки — всегда JSON с одной и той же схемой во всех эндпоинтах.
- Есть стабильный
code, а не толькоmessage. - Для валидации — массив
errorsсfieldв каждом. - Для
5xxи важных4xxестьrequest_id, он же в заголовкеX-Request-Id. - В OpenAPI описана схема
Errorи хотя бы один-дваexamplesна каждый4xx. - Существует страница «Error codes» со всеми возможными
code. - HTTP-статусы расставлены последовательно, один класс ошибок — один статус.
- В
messageнет пользовательского ввода без экранирования.
Хорошо описанные ошибки — это то, что делает API приятным для интеграции. На happy path API похожи друг на друга, отличия начинаются на ошибках. Клиент, который один раз залип на «error: invalid», запоминает это надолго. И наоборот — четкая схема и страница с кодами обычно превращают часть тикетов в саппорт в самостоятельные правки на стороне клиента.