Когда запрос не удался, клиент получает ошибку. Раньше каждый сервис возвращал её по-своему: кто-то JSON с полем message, кто-то просто строку, кто-то вообще пустое тело с кодом 500. Клиенту приходилось угадывать формат для каждого API.
RFC 9457 Problem Details фиксирует единую структуру для всех ошибок. Любой сервис, который её соблюдает, возвращает предсказуемый JSON — клиент знает, что делать, не читая документацию каждый раз.
Разница видна на одном запросе — форме, где пользователь ошибся сразу в трёх полях.
Формат тела ошибки решает, сколько раз пользователь нажмёт «Отправить»: свободный 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 |
status | HTTP-код (дублирует код ответа для удобства) |
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, чтобы не раскрывать его существование, и делает это запрос по владельцу, а не проверка прав после загрузки.
Что почитать дальше
- REST API — обзор раздела — все темы раздела по REST.
- JSON и формат ответов — как выглядит успешный ответ.
- Заголовки HTTP —
traceparentи какtraceIdпопадает в ответ.