← назад к разделу

Когда запрос не удался, клиент получает ошибку. Раньше каждый сервис возвращал её по-своему: кто-то 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
statusHTTP-код (дублирует код ответа для удобства)
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 попадает в ответ.