Когда запрос не удался, клиент получает ошибку. Раньше каждый сервис возвращал её по-своему: кто-то JSON с полем message, кто-то просто строку, кто-то вообще пустое тело с кодом 500. Клиенту приходилось угадывать формат для каждого API.
RFC 9457 Problem Details фиксирует единую структуру для всех ошибок. Любой сервис, который её соблюдает, возвращает предсказуемый JSON — клиент знает, что делать, не читая документацию каждый раз.
Структура тела ошибки
Ответ на неудачный запрос выглядит так:
{
"type": "urn:problem:order-service:order-not-found",
"status": 404,
"title": "Order not found",
"detail": "Заказ с указанным идентификатором не найден",
"instance": "urn:uuid:9f2d6c22-8e6d-4c2a-9b41-6b9a5e2f6c10",
"traceId": "00-1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c-7a8b9c0d1e2f3a4b-01",
"code": "ORDER_NOT_FOUND"
}
Что означает каждое поле:
| Поле | Назначение |
|---|---|
type | Стабильный идентификатор категории ошибки — URI или URN |
status | HTTP-код (дублирует код ответа для удобства) |
title | Короткое название, обычно совпадает с названием HTTP-кода |
detail | Понятное пользователю объяснение — что пошло не так |
instance | Уникальный идентификатор конкретного инцидента |
traceId | Идентификатор трассировки — чтобы найти этот запрос в логах |
code | Символьный код для программной логики на клиенте |
Content-Type для ошибок
Ответ с ошибкой должен иметь заголовок Content-Type: application/problem+json, а не обычный application/json. Это позволяет клиенту понять по типу содержимого: пришла ошибка, а не успешный ответ.
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "urn:problem:order-service:order-not-found",
...
}
Частая ошибка — вернуть ошибку с Content-Type: application/json. Клиент не сможет автоматически её распознать.
Поле type — идентификатор категории
type — это не описание конкретного инцидента, а идентификатор категории ошибки. Он не меняется: каждый раз, когда заказ не найден, type один и тот же.
Есть два варианта:
URL на страницу документации — если у вас есть портал или внутренняя вики, где описана эта ошибка:
"type": "https://errors.example.com/order/not-found"
Страница объясняет: что за ошибка, почему возникает, как исправить.
URN — если портала нет:
"type": "urn:problem:order-service:order-not-found"
"type": "urn:problem:payment-service:insufficient-balance"
Формат: urn:problem:<имя-сервиса>:<код-ошибки>. URN не требует развёрнутого сайта, при этом остаётся машиночитаемым и уникальным.
Что категорически нельзя использовать как type: about:blank. Это значение ничего не говорит клиенту о том, что произошло, — вся машиночитаемость теряется.
Поле code — для программной логики
detail — для пользователя, он может быть на русском и меняться. А code — для программного кода на клиенте. Это константа в формате UPPER_SNAKE_CASE:
ORDER_NOT_FOUND
VALIDATION_ERROR
RATE_LIMIT_EXCEEDED
EXT_SYSTEM_UNAVAILABLE
INSUFFICIENT_BALANCE
Клиент пишет логику через code, а не пытается парсить URI:
switch (error.code) {
case 'ORDER_NOT_FOUND': showNotFoundPage(); break;
case 'EXT_SYSTEM_UNAVAILABLE': showRetryButton(); break;
}
Все возможные значения code перечисляются как enum в OpenAPI-контракте — клиент заранее знает полный список.
Ошибки валидации — поле violations
Когда пользователь отправил форму с несколькими ошибками, важно вернуть все проблемы сразу, а не только первую. Иначе пользователь исправит одно поле, нажмёт «Отправить» снова — и увидит следующую ошибку. Плохой опыт.
Для ошибок валидации добавляется массив violations:
{
"type": "urn:problem:order-service:validation-error",
"status": 400,
"title": "Bad Request",
"detail": "Ошибка валидации входных данных",
"code": "VALIDATION_ERROR",
"violations": [
{ "field": "amount", "message": "Сумма должна быть больше 0" },
{ "field": "deliveryAddress.zipCode", "message": "Почтовый индекс обязателен" },
{ "field": "items[0].quantity", "message": "Количество должно быть от 1 до 99" }
]
}
Как указывается путь к полю:
- Вложенные поля — через точку:
deliveryAddress.zipCode - Элементы массива — с индексом:
items[0].quantity - Ошибка всего объекта — поле
fieldотсутствует или пустая строка
Как это выглядит в Spring
Spring Boot содержит класс ProblemDetail начиная с версии 6 (Spring Boot 3). Глобальный обработчик исключений:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ProblemDetail> handle(OrderNotFoundException ex) {
var problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problem.setType(URI.create("urn:problem:order-service:order-not-found"));
problem.setTitle("Order not found");
problem.setDetail("Заказ с указанным идентификатором не найден");
problem.setProperty("code", "ORDER_NOT_FOUND");
problem.setProperty("traceId", MDC.get("traceId"));
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
}
Ключевые моменты: MediaType.APPLICATION_PROBLEM_JSON в contentType, traceId из MDC (куда его кладёт трассировка), code как отдельное свойство.
Какие HTTP-коды использовать
Выбор кода — не произвольный. Вот что когда применяется:
| Код | Когда |
|---|---|
400 Bad Request | Невалидное тело запроса, неправильные параметры |
401 Unauthorized | Токен отсутствует или истёк — нужна аутентификация |
403 Forbidden | Токен есть, но доступ запрещён |
404 Not Found | Запрошенный объект по ID не существует |
409 Conflict | Одновременное изменение, дубликат ресурса |
410 Gone | Эндпоинт удалён и больше не вернётся |
429 Too Many Requests | Превышен лимит запросов |
500 Internal Server Error | Неожиданная ошибка на сервере |
Коды 400 и 500 присутствуют всегда. Остальные — в зависимости от того, что делает эндпоинт.
Коды 418, 422, 451 и другие нестандартные лучше не использовать — они вносят путаницу. Если что-то не попадает под стандартный список, чаще всего это 400 или 409.
Что не должно попасть в тело ошибки
Когда случается неожиданная ошибка на сервере, в ответ возвращается 500. Но не всё подряд:
- Трассировка стека — её клиенту не нужно видеть. Вместо этого —
traceId, по которому разработчик найдёт всё в логах. - SQL-запросы — раскрывают внутреннюю структуру базы данных.
- Внутренние пути файлов — тоже лишняя информация для клиента.
- Персональные данные в
detail— если ошибка связана с пользователем, пишем общее сообщение, не конкретику.
Простое правило: тело 500 содержит traceId и общую фразу типа «Внутренняя ошибка сервера». Всё остальное — в логах.
Коротко
- RFC 9457 Problem Details — стандарт тела ошибки для REST API. Одна структура для всех 4xx/5xx.
- Обязательные поля:
type,status,title,detail,code. ПлюсtraceIdдля трассировки иinstanceдля уникального инцидента. Content-Typeошибочного ответа:application/problem+json, неapplication/json.type— стабильный идентификатор категории: URL на документацию илиurn:problem:<сервис>:<код>. Никогда неabout:blank.code— константа вUPPER_SNAKE_CASEдля программной логики на клиенте.- Ошибки валидации:
400+code: VALIDATION_ERROR+ массивviolationsсо всеми полями сразу. - Путь к полю в
violations: точка для вложенных (deliveryAddress.zipCode), индекс для массивов (items[0].quantity). 500: толькоtraceIdи общая фраза. Стек, SQL, пути файлов — в ответ не попадают.
Что почитать дальше
- REST API — обзор раздела — все темы раздела по REST.
- JSON и формат ответов — как выглядит успешный ответ.
- Заголовки HTTP —
traceparentи какtraceIdпопадает в ответ.