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

Когда запрос не удался, клиент получает ошибку. Раньше каждый сервис возвращал её по-своему: кто-то JSON с полем message, кто-то просто строку, кто-то вообще пустое тело с кодом 500. Клиенту приходилось угадывать формат для каждого API.

RFC 9457 Problem Details фиксирует единую структуру для всех ошибок. Любой сервис, который её соблюдает, возвращает предсказуемый JSON — клиент знает, что делать, не читая документацию каждый раз.

Разница видна на одном запросе — форме, где пользователь ошибся сразу в трёх полях.

POST /orders — в теле три ошибки валидации как раньше: свой формат Problem Details, RFC 9457 Content-Type:application/json{"message": "Сумма больше 0"}первая ошибка из трёх клиент разбирает текстподсветил одно поле Content-Type:application/problem+json"code": "VALIDATION_ERROR""violations": все три поля switch (error.code)подсветил все три поля 4 раза до успеха2 раза до успехавсе ошибки в одном ответе — вдвое меньше нажатий «Отправить»

Формат тела ошибки решает, сколько раз пользователь нажмёт «Отправить»: свободный message отдаёт ошибки по одной — три отказа подряд и только четвёртая отправка проходит, а violations со всеми тремя полями плюс машиночитаемый code сводят дело к одному отказу и одной повторной отправке.

Обязательно

Структура тела ошибки

Ответ на неудачный запрос выглядит так:

{
  "type": "urn:problem:order-service:order-not-found",
  "status": 404,
  "title": "Order not found",
  "detail": "Заказ с указанным идентификатором не найден",
  "instance": "/api/v1/orders/42",
  "traceId": "1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c",
  "code": "ORDER_NOT_FOUND"
}

Что означает каждое поле:

ПолеНазначение
typeСтабильный идентификатор категории ошибки — URI или URN
statusHTTP-код (дублирует код ответа для удобства)
titleКороткое название, обычно совпадает с названием HTTP-кода
detailПонятное пользователю объяснение — что пошло не так
instanceСсылка на конкретный случай проблемы: чаще всего путь запроса, реже — свой идентификатор инцидента
traceIdИдентификатор трассировки — чтобы найти этот запрос в логах
codeСимвольный код для программной логики на клиенте

Два поля стоит разобрать отдельно, потому что их путают чаще прочих.

instance в стандарте описан как ссылка на конкретное появление проблемы — в отличие от type, который обозначает категорию. На практике туда кладут путь запроса, который эту ошибку вызвал (/api/v1/orders/42): дёшево, понятно и сразу видно, о каком объекте речь. Реже пишут собственный номер инцидента вида urn:uuid:9f2d6c22-… — так делают, когда по этому номеру можно найти запись в системе поддержки. Оба варианта законны, но чужие API чаще отдают именно путь, и ждать там UUID не стоит.

traceId — это ровно идентификатор трассировки, 32 шестнадцатеричных знака, и ничего больше. В заголовке traceparent он лежит вторым куском из четырёх:

traceparent: 00-1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c-7a8b9c0d1e2f3a4b-01
             ^^ версия  ^^ trace-id (вот он)   ^^ span-id     ^^ флаги

Частая ошибка — положить в поле traceId весь заголовок целиком. Тогда значение перестаёт совпадать с тем, что лежит в логах, и поиск по нему ничего не находит — а ради поиска поле и заводили. Подробный разбор traceparent — в статье про заголовки.

Обязательных полей в стандарте нет

Отдельно стоит сказать, чего RFC 9457 не требует. Ни одно из перечисленных полей не объявлено обязательным: стандарт описывает их значения, но разрешает прислать хоть пустой объект. Если type не прислали, считается, что там about:blank. А поля code в стандарте нет вовсе — это наше расширение, стандарт прямо разрешает добавлять свои поля рядом.

Практический вывод двойной. Внутри своей команды договоритесь о минимуме — например, type, status, title, detail, code — и держите его во всех сервисах: тогда клиент пишет разбор ошибки один раз. А вот у чужого API рассчитывать на эти поля нельзя: там может прийти один status, и клиентский код обязан это пережить.

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. Клиент не сможет автоматически её распознать.

Вот как один и тот же эндпоинт отвечает на существующий и на несуществующий товар. Запрос отличается одним идентификатором:

живой пример

GET /api/v1/products/11111111-1111-4111-8111-111111111111

HTTP/1.1 200 OK
Content-Type: application/json

{ "productId": "11111111-...", "name": "Клавиатура", "price": "2490.00" }
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

GET /api/v1/products/99999999-9999-4999-8999-999999999999

HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
  "type": "urn:problem:catalog-service:product-not-found",
  "status": 404,
  "title": "Product not found",
  "detail": "Товар с указанным идентификатором не найден",
  "code": "PRODUCT_NOT_FOUND"
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Успех и ошибка отличаются тремя вещами сразу: кодом ответа, типом содержимого и формой тела. Клиенту хватает второго, чтобы понять, какой из двух разборов запускать, — и не приходится гадать по наличию поля error.

Поле type — идентификатор категории

Зачем type, если есть code? Они отвечают на разные вопросы. code нужен коду клиента, чтобы ветвиться: на ORDER_NOT_FOUND показать одно, на INSUFFICIENT_FUNDS другое. 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 не требует развёрнутого сайта, при этом остаётся машиночитаемым и уникальным.

Значение about:blank в поле type стандарт разрешает: это значение по умолчанию, и означает оно «ничего сверх кода статуса тут нет». Для ошибок, которые клиент должен различать в коде, оно бесполезно — машиночитаемость теряется. Поэтому договоритесь в команде: у всех прикладных ошибок 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 отсутствует или пустая строка

Как ошибки валидации попадают в violations

Обработчик своего исключения показан, а главный источник ошибок — проверка входных данных — остался без кода. Между тем именно её надо перехватывать явно: Spring бросает своё исключение, и без обработчика клиент получит стандартный ответ, не похожий на остальные ваши ошибки.

Для тела запроса (@RequestBody @Valid) это MethodArgumentNotValidException, внутри которого лежит результат привязки со списком нарушений:

@RestControllerAdvice
public class ValidationExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ProblemDetail> onInvalidBody(MethodArgumentNotValidException e) {
        List<Violation> violations = e.getBindingResult().getFieldErrors().stream()
                .map(fe -> new Violation(fe.getField(), fe.getDefaultMessage(), fe.getCode()))
                .toList();

        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setType(URI.create("https://api.example.com/problems/validation-failed"));
        problem.setTitle("Проверка не пройдена");
        problem.setProperty("code", "VALIDATION_FAILED");
        problem.setProperty("violations", violations);
        return ResponseEntity.badRequest().body(problem);
    }

    record Violation(String field, String message, String rule) {}
}

Три вещи, которые надо знать про этот код.

Ошибок бывает несколько, и отдавать надо все сразу — иначе клиент правит поля по одному, получая новую ошибку на каждый запрос. Поэтому getFieldErrors() целиком, а не первый элемент.

Кроме полей бывают ошибки уровня объекта (getGlobalErrors()): проверка «дата окончания позже даты начала» не относится ни к одному полю. Их кладут в тот же список с пустым или составным именем поля.

Три разных исключения на одну задачу. Тело запроса даёт MethodArgumentNotValidException; параметры метода с @Validated на классе — ConstraintViolationException (и путь к полю там другой, с именем метода внутри); привязка параметров запроса без тела — BindException. Обрабатывать надо все три, иначе часть ошибок валидации будет выглядеть иначе, чем остальные.

Отдельно: имя поля в ответе должно быть именем из контракта, а не из Java-класса. Если в модели поле customerId, а в JSON оно customer_id, клиент получит указание на поле, которого не видел. Это тот случай, когда сопоставление имён приходится применять и к ошибкам.

Где ловить: границы обработчика

@RestControllerAdvice перехватывает исключения, вылетевшие из контроллера, — и только их. Всё, что случилось раньше или в стороне, до него не доедет, и это надо знать, иначе часть ошибок будет выглядеть чужой.

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

Ошибки безопасности. Отказ аутентификации (401) и авторизации (403) в Spring Security формируются в фильтрах, то есть тоже мимо обработчика. Чтобы они выглядели как остальные ошибки, настраивают точки входа безопасности (authenticationEntryPoint и accessDeniedHandler) и отдают из них тот же формат. Это самая частая причина, по которой в одном API две разные формы ошибок.

Ошибки до разбора запроса. Неверный JSON (HttpMessageNotReadableException), не тот метод (HttpRequestMethodNotSupportedException), не тот тип содержимого (HttpMediaTypeNotSupportedException) — они доезжают до обработчика, но по умолчанию обрабатываются базовым классом. Отсюда выбор: наследовать ResponseEntityExceptionHandler (получить готовую обработку десятка стандартных исключений и переопределить формат в одном методе) или писать @RestControllerAdvice с нуля (полный контроль, но каждое стандартное исключение придётся описать самому). Для нового сервиса обычно берут первое и переопределяют один метод сборки тела.

Ошибки сериализации ответа. Исключение, возникшее при записи ответа (когда часть байт уже ушла клиенту), обработчиком не исправить: статус уже отправлен. Поэтому тяжёлую логику не оставляют в геттерах модели ответа.

Каких кодов не хватает в таблице

Пять кодов, которые встречаются в жизни и путаются чаще остальных.

405 Method Not Allowed — путь есть, метод не поддержан; обязателен заголовок Allow со списком разрешённых.

406 Not Acceptable и 415 Unsupported Media Type — про форматы, и их постоянно путают с 400. Правило: 415 — «не понимаю, что ты прислал» (не тот Content-Type), 406 — «не умею отдать то, что ты просишь» (Accept), 400 — «формат тот, содержимое неверное».

412 Precondition Failed и 428 Precondition Required — про условные запросы. 412 отвечают, когда клиент прислал If-Match с устаревшей версией: кто-то изменил ресурс раньше. 428 — когда сервер требует условие, а клиент его не прислал: так защищают изменение от слепой перезаписи. Разбор — в статье про заголовки.

503 Service Unavailable с Retry-After — «сейчас не могу, приходи позже»: перегрузка, обслуживание, отказ зависимости. Отличие от 429: 429 — это «ты просишь слишком часто» (виноват клиент), 503 — «мне плохо» (виноват сервер). И то и другое клиенту можно повторять, но с разной логикой: на 429 он ждёт указанное время, на 503 — отступает с нарастающей паузой.

409 Conflict — состояние ресурса не позволяет операцию (заказ уже оплачен, имя занято). Его часто подменяют на 400, а разница важна: 400 означает «исправь запрос», 409 — «запрос правильный, но не сейчас или не с этим состоянием».

Что делать клиенту: признак повторяемости

Тело ошибки должно отвечать не только «что случилось», но и «стоит ли пробовать снова». Клиент не обязан помнить наизусть, какие ваши коды временные, — поэтому признак кладут прямо в ответ.

{
  "type": "https://api.example.com/problems/payment-provider-unavailable",
  "title": "Платёжный провайдер недоступен",
  "status": 503,
  "code": "PAYMENT_PROVIDER_UNAVAILABLE",
  "retryable": true,
  "retryAfterSeconds": 30,
  "traceId": "0af7651916cd43dd8448eb211c80319c"
}

Правило распределения простое. Повторять можно то, что вызвано состоянием системы: 429, 503, таймауты, временный отказ зависимости, конфликт версий при оптимистичной блокировке. Повторять бессмысленно то, что вызвано самим запросом: 400, 401, 403, 404, 409 по бизнес-правилу, 422. Для повторяемых уместен Retry-After (в заголовке или в теле), для остальных его не отдают вовсе.

И обратная сторона: идемпотентность на стороне сервера — условие, без которого совет «повторяйте» опасен. Если повтор создаёт второй заказ, признак повторяемости становится ловушкой; поэтому он идёт в паре с поддержкой ключа идемпотентности.

Каталог кодов: кто его ведёт

Поле с машинным кодом полезно ровно настолько, насколько оно предсказуемо. Значит, у кодов должен быть каталог, и у каталога — правила.

Где живёт. В одном месте: перечисление в коде плюс раздел в описании API (и лучше, чтобы описание генерировалось из перечисления, а не поддерживалось руками). Код, который есть в ответе и отсутствует в описании, — дефект.

Кто добавляет. Тот, кто добавляет ошибку, — и в том же изменении, что и код ошибки. Проверяется это на ревью: новый code без строки в каталоге не проходит.

Формат. Устойчивая форма — ДОМЕН_СУТЬ в верхнем регистре: ORDER_ALREADY_PAID, PAYMENT_LIMIT_EXCEEDED. Не номера (E1043): их невозможно читать в журнале и легко перепутать. Не сообщения на языке пользователя: сообщение меняется, код — нет.

Почему добавление кода — не ломающее изменение. Клиент обязан обрабатывать неизвестный код по коду состояния HTTP: увидел 409 с незнакомым code — показал сообщение из detail и не стал строить особую логику. Именно поэтому в контракте пишут «список кодов будет расширяться», а клиент никогда не сопоставляет код с switch без ветки по умолчанию. А вот изменение смысла существующего кода — ломающее изменение, и делают его через новый код плюс вывод старого из эксплуатации.

Когда формат можно упростить

Полная форма нужна публичному API. Между своими сервисами часть полей — мёртвый груз, и стоит это признать.

detail на русском языке в машинном контуре бесполезен: его никто не покажет пользователю, а в журнале он занимает место и мешает поиску. type со ссылкой на документацию тоже: внутренние клиенты по ссылкам не ходят. Что действительно нужно между своими: код состояния, машинный code, признак повторяемости и идентификатор трассировки, по которому можно найти причину в журналах.

Отсюда практическая раскладка: один формат ошибки на организацию, но с разной полнотой. Наружу — с человеческим сообщением, ссылкой и локализацией; внутрь — короткая форма. Главное, чтобы структура была одна: тогда общая библиотека разбора работает и там, и там.

Как это выглядит в Spring

Класс ProblemDetail появился в Spring Framework 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 как отдельное свойство.

Половину ошибок ваш обработчик не увидит

Тут кроется неприятный сюрприз. Написали advice, прогнали свои исключения, всё красиво — а потом кто-то шлёт битый JSON, и в ответ прилетает совсем другое:

{
  "timestamp": "2026-05-26T10:30:00.123+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/api/v1/orders"
}

Это не ваш формат. Так отвечает сам Spring на то, до чего ваш обработчик не добрался: путь не найден (404), метод не тот (405), тип содержимого не поддержан (415), тело не разобралось. Такие ошибки Spring ловит раньше и отдаёт по своему умолчанию — с Content-Type: application/json и полями timestamp/error/path.

Чинится это одной строкой:

spring:
  mvc:
    problemdetails:
      enabled: true

По умолчанию она выключена. Включённая — переводит собственные исключения Spring на application/problem+json с полями type, title, status, detail. Формат ответа становится единым и для ваших ошибок, и для тех, что случились до входа в контроллер. Без неё половина ответов остаётся в том самом виде, от которого мы и уходили.

Какие 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 присутствуют всегда. Остальные — в зависимости от того, что делает эндпоинт.

Два кода из этого ряда законны, но требуют осознанности. 422 описан в основном стандарте HTTP и означает «запрос понят и синтаксически верен, но выполнить его нельзя»; 451 значит «доступ закрыт по юридическим причинам». Если сомневаетесь, 400 или 409 поймут все.

А 418 использовать не стоит: в самом HTTP такого кода нет. Он появился в первоапрельском RFC 2324, шуточном стандарте протокола управления кофейником, где 418 I'm a teapot означает «я чайник, кофе сварить не могу», и в реестре кодов HTTP номер после этого зарезервировали как неиспользуемый, чтобы никто не занял его всерьёз.

Что не должно попасть в тело ошибки

Когда случается неожиданная ошибка на сервере, в ответ возвращается 500. Но не всё подряд:

  • Трассировка стека — её клиенту не нужно видеть. Вместо этого — traceId, по которому разработчик найдёт всё в логах.
  • SQL-запросы — раскрывают внутреннюю структуру базы данных.
  • Внутренние пути файлов — тоже лишняя информация для клиента.
  • Персональные данные в detail — если ошибка связана с пользователем, пишем общее сообщение, не конкретику.

Простое правило: тело 500 содержит traceId и общую фразу типа «Внутренняя ошибка сервера». Всё остальное — в логах.

Дополнительно: при первом чтении можно пропустить

Глубже: 401, 403 и 404: кто вы, что вам можно и чего вы не должны узнатьрасширенное

Три кода из таблицы выше путают чаще остальных, а разница между ними это разница между «исправь запрос» и «утечка данных».

401 Unauthorized вопреки названию не про права, а про личность: сервер не знает, кто перед ним. Токена нет, он истёк, подпись не сошлась. Ответ обязан сказать, как представиться, заголовком WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="expired"; по нему клиент понимает, что нужно обновить токен, а не показывать пользователю «доступ запрещён». Тело в формате ошибок при этом обычное, с type и code.

403 Forbidden это «я знаю, кто вы, и вам нельзя»: роль не та, область доступа токена не включает операцию (orders:write), тариф не позволяет. Повторять с тем же токеном бессмысленно, и клиенту полезно сказать в теле, какой области не хватает. Различие с 401 важно для клиента: на 401 он идёт за новым токеном, на 403 показывает пользователю честное сообщение.

404 Not Found для чужого ресурса это не ошибка, а правило проектирования. Если GET /orders/42 чужого пользователя отвечает 403, вы сообщили, что заказ 42 существует, и перебором идентификаторов можно узнать число заказов, их темп и чьи они. Поэтому объект, к которому у пользователя нет доступа, для него не существует: 404, тот же, что и для несуществующего. Исключение делают, когда существование и так публично, а закрыто содержимое: приватный репозиторий по прямой ссылке, например, но и там многие отвечают 404.

В Spring это раскладывается так: 401 и WWW-Authenticate ставит Spring Security через AuthenticationEntryPoint, 403 через AccessDeniedHandler, и оба нужно перенастроить на формат ошибок API, иначе они ответят пустым телом или HTML. А 404 вместо 403 для чужого объекта делает не Security, а репозиторий: запрос вида findByIdAndOwnerId(id, currentUser) не находит чужое, и дальше срабатывает обычная ветка «не найдено». Как схемы доступа и области описываются в контракте, показывает статья про OpenAPI.

Коротко

  • RFC 9457 Problem Details — стандарт тела ошибки для REST API. Одна структура для всех 4xx/5xx.
  • Обязательных полей у стандарта нет — все необязательны, а code в нём вообще не описан, это наше расширение. Наш минимум по договорённости команды: type, status, title, detail, code, плюс traceId для трассировки и instance со ссылкой на конкретный случай (обычно путь запроса).
  • В traceId кладут только сам идентификатор трассировки — 32 знака из середины traceparent, а не весь заголовок.
  • Content-Type ошибочного ответа: application/problem+json, не application/json.
  • spring.mvc.problemdetails.enabled: true — иначе ошибки, которые Spring ловит сам (404, 405, 415, битое тело), придут в его формате. А исключения из фильтров и отказы Spring Security (401/403) до обработчика не доезжают вовсе: им нужны свои точки входа, иначе в одном API будет две формы ошибок.
  • type — стабильный идентификатор категории: URL на документацию или urn:problem:<сервис>:<код>. Оставлять about:blank там, где клиенту нужно различать ошибки, бессмысленно.
  • code — константа в UPPER_SNAKE_CASE для логики на клиенте, живёт в каталоге рядом с описанием API; добавление кода не ломающее изменение (клиент обязан иметь ветку по умолчанию), изменение смысла — ломающее. Рядом полезен признак повторяемости: 429, 503 и таймауты повторяют, 400, 403, 409 — нет.
  • Ошибки валидации: 400 + code: VALIDATION_ERROR + массив violations со всеми полями сразу, путь к полю с точкой и индексом (items[0].quantity) и именами из контракта. Собирают их три обработчика: MethodArgumentNotValidException для тела, ConstraintViolationException для параметров, BindException для привязки.
  • 500: только traceId и общая фраза. Стек, SQL, пути файлов — в ответ не попадают.
  • 401 это «не знаю, кто вы» с WWW-Authenticate, 403 это «знаю, и нельзя»; чужой объект отдают как 404, чтобы не раскрывать его существование, и делает это запрос по владельцу, а не проверка прав после загрузки.

Что почитать дальше