Обработка ошибок: единый формат и RFC 7807
Урок о том, как сообщать клиенту, что пошло не так, — чтобы это было понятно и человеку, и машине.
Problem Details (RFC 7807) — это стандартизированный JSON-формат для машинно-читаемого описания ошибок HTTP API с типом содержимого
application/problem+json.
HTTP-статус-код говорит, что запрос провалился, но не говорит, почему. Код 400 на регистрации может значить «email занят», «пароль слишком короткий», «не передано имя» — всё это один статус. Если ответ на каждую ошибку выглядит по-разному (где-то строка, где-то {"error": "..."}, где-то {"message": "..."}), клиент вынужден писать развесистый код парсинга под каждый эндпоинт. Единый формат ошибок превращает обработку в одну ветку: «увидел ошибку — распарсил по известной схеме».
Почему нужен единый формат
Ошибки — такая же часть контракта API, как и успешные ответы. Клиент должен уметь: показать понятное сообщение пользователю, отличить «исправь запрос» от «попробуй позже», подсветить конкретное невалидное поле в форме, залогировать машинный код для аналитики. Всё это возможно только если формат ошибки предсказуем и одинаков на всех эндпоинтах. Хаотичные ответы об ошибках — главный признак незрелого API.
Базовые поля единого формата
Минимально полезная ошибка содержит три вещи:
- code — стабильный машинный идентификатор (
EMAIL_TAKEN, а не текст). По нему клиент строит логику, и он не меняется при правке формулировки. - message — человекочитаемое сообщение для разработчика/пользователя.
- details — структурированные подробности: список невалидных полей, ссылка на документацию, идентификатор запроса для саппорта.
{
"code": "VALIDATION_ERROR",
"message": "Не удалось сохранить пользователя",
"details": [
{ "field": "email", "code": "ALREADY_TAKEN", "message": "Email уже зарегистрирован" },
{ "field": "password", "code": "TOO_SHORT", "message": "Минимум 8 символов" }
]
}
Стандарт: Problem Details (RFC 7807)
Чтобы не изобретать формат заново, есть стандарт — RFC 7807 (обновлён как RFC 9457). Он определяет JSON-объект с типом содержимого application/problem+json и набором стандартных полей:
| Поле | Назначение |
type | URI, идентифицирующий тип проблемы (часто ведёт на доку). Главный машинный идентификатор. |
title | Короткое человекочитаемое название типа проблемы, не зависит от экземпляра. |
status | HTTP-статус-код, дублирует код ответа (удобно при логировании). |
detail | Человекочитаемое объяснение этого конкретного случая. |
instance | URI конкретного экземпляра проблемы (например, ссылка на запрос/ресурс). |
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Недостаточно средств",
"status": 403,
"detail": "На счёте 30.00, для операции нужно 50.00",
"instance": "/accounts/12345/transactions/abc"
}
Стандарт разрешает расширять объект своими полями. Это удобно для ошибок валидации:
{
"type": "https://api.example.com/problems/validation",
"title": "Ошибка валидации",
"status": 422,
"detail": "Тело запроса не прошло проверку",
"instance": "/users",
"errors": [
{ "field": "email", "message": "Некорректный формат" },
{ "field": "age", "message": "Должно быть не меньше 18" }
]
}
Полный ответ сервера с этим телом выглядит так:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{ ... тело Problem Details ... }
Ошибки валидации: список полей
Валидация — особый случай: ошибок может быть много сразу, и каждая привязана к полю. Хороший API возвращает все ошибки разом, а не первую попавшуюся, — иначе пользователь чинит форму по одному полю за запрос. Каждый элемент списка содержит как минимум адрес поля и сообщение; полезно добавить машинный code, чтобы клиент мог локализовать текст на своей стороне.
Сообщения для людей и для машин
Хорошая ошибка двуязычна. Для машины — стабильный type/code, по которому строится логика и который не ломается при изменении формулировки. Для человека — detail/message, который можно показать или перевести. Не путайте их: завязывать if на текст сообщения («если message == 'Email занят'») — хрупко, потому что текст меняют, переводят, правят опечатки. Машинный код — контракт, текст — UX.
Как работает под капотом
На практике единый формат реализуют через централизованный обработчик исключений. Бизнес-код бросает типизированные ошибки (EmailTakenError, ValidationError), а один глобальный обработчик ловит их на границе приложения и превращает в Problem Details: подбирает HTTP-статус, заполняет type/title, выставляет заголовок Content-Type: application/problem+json. Так формат гарантированно одинаков на всех эндпоинтах, и его не нужно вручную собирать в каждом контроллере. Непойманные исключения попадают в общий fallback, который отдаёт нейтральный 500 без подробностей — и логирует настоящую причину на сервере.
Частые ошибки
- Раскрывать стек-трейс и внутренности. В ответ нельзя класть трейсбек, SQL-запрос, имена таблиц, пути файлов — это и помощь атакующему, и шум для клиента. Внутренние детали — в серверный лог, наружу — нейтральное
detailи500. - Отдавать 200 OK с ошибкой в теле.
{"success": false}при статусе200ломает стандартную обработку: прокси, кеши и клиенты считают запрос успешным. Статус-код обязан отражать результат. - Завязывать логику на текст сообщения. Текст — для людей, он меняется. Логику стройте на
type/code. - Возвращать только первую ошибку валидации. Отдавайте список всех проблемных полей сразу.
- Разный формат на разных эндпоинтах. Главная ценность стандарта — единообразие; нарушать его внутри одного API нельзя.
Итоги
- Ошибки — часть контракта; формат должен быть единым на всех эндпоинтах.
- Минимум полей: машинный
code, человекочитаемыйmessage, структурированныеdetails. - RFC 7807 (Problem Details) даёт готовый стандарт:
type/title/status/detail/instanceиContent-Type: application/problem+json; его можно расширять своими полями. - Валидацию возвращайте списком всех полей сразу, с машинным кодом для локализации.
- Никогда не раскрывайте стек и внутренности; статус-код обязан отражать результат.