Перейти к содержанию

Единая форма ошибок API

Предыдущий урок показал несколько причин отказа: неверный синтаксис, неприемлемое поле и отсутствие записи. Клиенту неудобно обрабатывать отдельную произвольную оболочку для каждой причины. Problem Details предоставляет общую форму описания HTTP-проблемы, к которой API может добавить собственные структурированные сведения.

В этом уроке определим ошибки каталога на основе RFC 9457 и отделим стандартные члены от расширений нашего сервиса. Результат — клиент понимает общий отказ и связывает предметную ошибку с полем, не разбирая русскую фразу. Сообщения составлены автором; реальная обработка и сетевой обмен не выполнялись.

Тип проблемы и конкретный случай

Начнём с ошибки lessons из предыдущего урока. Ниже целиком приведён ожидаемый ответ с добавленным идентификатором случая:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Cache-Control: no-store

{"type":"https://catalog.example/problems/validation","title":"Недопустимые поля курса","status":422,"detail":"Поле lessons должно содержать целое число.","instance":"https://catalog.example/problem-occurrences/p001","errors":[{"pointer":"/lessons","code":"integer-required","detail":"Ожидается целое число от 1 до 1000."}]}

type идентифицирует вид проблемы. Все ошибки принятой предметной валидации могут относиться к одному виду, хотя конкретное поле и текст различаются. instance обозначает именно этот случай: следующий независимый отказ получит другую ссылку. Одно поле описывает категорию, другое — произошедшее событие.

В примере использованы абсолютные URI, чтобы их смысл не менялся при разных путях запросов. Это учебные идентификаторы на зарезервированном домене. Будущая реализация должна подготовить документацию type, если использует такие адреса как доступные ссылки. Для instance доступность не обязательна: идентификатор может служить корреляции, а не публичной странице с журналом.

Клиент не обязан автоматически загружать каждую ссылку type. Он способен заранее знать договор распространённой проблемы и показать общий отказ для незнакомой. Иначе простая ошибка формы неожиданно вызовет дополнительный сетевой запрос и зависимость от документации. Ссылки помогают разработчику понять тип, но не являются командой выполнения.

Статус и текст имеют разные роли

Поле status повторяет код HTTP для удобства потребителя, особенно если объект сохранён отдельно от сообщения. Сервер формирует его согласованно с настоящим статусом. Нельзя вернуть 200 в стартовой строке и написать 422 внутри, считая это равнозначным отказом: общая HTTP-инфраструктура ориентируется на действительный код.

title кратко описывает вид проблемы и остаётся стабильным для этого типа, кроме перевода. detail поясняет конкретный случай и помогает его исправить. Поэтому название «Недопустимые поля курса» повторяется у ошибок title и lessons, а detail меняется. Это уменьшает произвольность сообщений и не заставляет клиента угадывать категорию по каждой фразе.

Машинная логика не должна искать подстроку «целое число» в detail. Текст может быть уточнён редактором или переведён, не меняя причины. Для необходимых структурированных сведений предназначены расширения. В нашем договоре массив errors содержит pointer, code и detail; его формат задаёт сервис, а не обязательный универсальный набор RFC. Члены Problem Details.

Ошибки нескольких полей

Изменим вход так, чтобы title состоял из пробелов, а lessons было равно нулю. После нормализации сервер найдёт две независимые предметные ошибки. Покажем полное тело результата, не повторяя уже известные HTTP-поля:

{
  "type":"https://catalog.example/problems/validation",
  "title":"Недопустимые поля курса",
  "status":422,
  "detail":"Исправьте название и количество уроков.",
  "instance":"https://catalog.example/problem-occurrences/p002",
  "errors":[
    {"pointer":"/title","code":"required","detail":"Укажите непустое название."},
    {"pointer":"/lessons","code":"out-of-range","detail":"Ожидается число от 1 до 1000."}
  ]
}

Pointer указывает на место в JSON-документе входа. Наши простые имена дают короткие пути. Если позже появятся вложенные объекты или необычные символы в ключах, потребуется соблюдать правила JSON Pointer, а не заменять точку на slash случайной функцией. Код причины остаётся машинной строкой, подходящей для стабильного отображения конкретного вида ошибки.

Клиент получает возможность показать общую область отказа и связать элементы с полями формы. При этом незнакомый code не должен полностью скрыть отказ. Можно оставить общее title и безопасный detail, сохранив введённые значения. Совместимость требует понятного поведения при новых расширениях, а не только красивой обработки заранее известных случаев.

Сам массив errors не говорит, что сервер частично применил остальные поля. В нашем PUT отказ сохраняет старое представление целиком. Это правило операции следует объяснить рядом с формой ошибки. Иначе клиент способен отметить исправленное title как сохранённое, хотя замена не состоялась. Общий формат сообщения не отменяет семантику предметного изменения.

Расширение не должно ломать общий обработчик

Потребитель Problem Details игнорирует неизвестные ему расширения, сохраняя понимание общих сведений. Если сервис позже добавит допустимые значения topic, старый клиент всё равно сможет показать title и detail. Нельзя сделать новое необязательное поле необходимым условием самого распознавания ошибки, иначе каждый небольшой выпуск сервера потребует одновременного обновления всех клиентов.

Это не освобождает от проверки известных полей. В нашем расширении errors ожидается массив объектов, а pointer и code имеют определённые роли. Если посредник вернул HTML вместо обещанной проблемы, общий обработчик не должен падать при чтении несуществующего массива. Он оставляет понятное сообщение по фактически известному статусу и сохраняет локальное состояние формы.

Публичный detail не обязан совпадать с записью внутреннего журнала. Для человека полезно знать, что lessons должно быть целым, а для разработчика — конкретную ветку обработчика и контекст. Связать эти уровни помогает instance, но подробный журнал остаётся доступен уполномоченной стороне. Не помещайте секретный запрос целиком в публичное тело ради удобства диагностики.

Наконец, незнакомый type может обозначать новую причину с тем же HTTP-статусом. Клиенту стоит предусмотреть общий отказ, а не трактовать неизвестное значение как успех. Формат ошибок допускает развитие, но не даёт оснований игнорировать сам факт неуспешной операции. Так единая форма помогает сохранять совместимость, не скрывая новые предметные состояния.

Общие и предметные причины

Для отсутствующего c999 используем другой type и 404, не добавляя errors с указанием поля формы. Это отказ чтения цели, а не проверка входного JSON:

{"type":"https://catalog.example/problems/course-not-found","title":"Курс не найден","status":404,"detail":"Курс c999 отсутствует.","instance":"https://catalog.example/problem-occurrences/p003"}

Идентификатор случая полезен при обращении редактора за помощью. Внутренний журнал может связать его с подробностями, но клиент не должен получать стек исключения, SQL или секретные заголовки. Problem Details описывает интерфейс отказа, а не открывает устройство реализации. Чем подробнее поле detail, тем важнее проверить, какие сведения действительно помогают исправить действие.

Не нужно придумывать отдельный предметный тип для каждого стандартного статуса. Иногда общего 403 уже достаточно, а добавление собственного URI не приносит новой семантики. Наши типы нужны там, где API даёт конкретные правила: например, что именно считается неприемлемым документом курса. Новая категория становится частью публичного договора, и её стоимость поддержки тоже следует учитывать.

Тип application/problem+json отличается от успешного application/json. Программный клиент явно предусматривает оба вида, например через Accept с двумя значениями. Он не должен безусловно трактовать любой JSON-совместимый текст как курс. Сначала проверяет статус и тип, затем применяет соответствующий договор содержимого.

В некоторых отказах тело может отсутствовать: учебный 406 уже определён так в уроке согласования. Следовательно, обработчик ошибок обязан иметь общий вариант по известному статусу без объекта проблемы. Единая форма упрощает данные, когда они есть, но не гарантирует JSON при сетевом обрыве или каждом ответе посредника.

Теперь можно описать новый отказ без изобретения новой оболочки: выбрать настоящий статус, стабильный type, краткое название и только необходимые детали случая. Для валидации добавить структурированные поля, сохраняя различие стандарта и расширения. Следующий урок рассмотрит более трудную ситуацию: запрос мог завершиться, но клиент не получил этот понятный ответ и собирается повторить действие.

Оглавление курса