Обработка ошибок: единый формат и 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 и набором стандартных полей:

ПолеНазначение
typeURI, идентифицирующий тип проблемы (часто ведёт на доку). Главный машинный идентификатор.
titleКороткое человекочитаемое название типа проблемы, не зависит от экземпляра.
statusHTTP-статус-код, дублирует код ответа (удобно при логировании).
detailЧеловекочитаемое объяснение этого конкретного случая.
instanceURI конкретного экземпляра проблемы (например, ссылка на запрос/ресурс).
{
  "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; его можно расширять своими полями.
  • Валидацию возвращайте списком всех полей сразу, с машинным кодом для локализации.
  • Никогда не раскрывайте стек и внутренности; статус-код обязан отражать результат.
Проверьте себя
1. Какой Content-Type предписывает RFC 7807 для тела ошибки?
Aapplication/json
Bapplication/problem+json
Ctext/error
Dapplication/vnd.error+json
2. Почему клиентскую логику нельзя завязывать на текст поля message/detail?
AТекст всегда на английском
BТекст сообщения меняется, переводится и правится, а машинный type/code стабилен
Cmessage запрещён стандартом RFC 7807
DТекст нельзя прочитать программно
3. Что НЕ следует возвращать клиенту в теле ошибки?
AHTTP-статус в поле status
BСтек-трейс, SQL-запрос и пути файлов сервера
CСписок невалидных полей
DСсылку на документацию по типу ошибки
4. Как правильно вернуть ошибку валидации формы с несколькими невалидными полями?
AСтатус 200 OK и {"success": false}
BПервую найденную ошибку и остановиться
CСтатус 4xx (например 422) и список всех проблемных полей в details/errors
DТолько текстовое сообщение без указания полей