JavaRush /Курсы /Spring REST & MVC /Каркас ProblemDetail

Каркас ProblemDetail: 5 полей

Spring REST & MVC
21 уровень , 0 лекция
Открыта

1. 404 недостаточно: ошибка тоже контракт

Когда вы делаете API, вы фактически подписываете договор с клиентом: «если ты пришлёшь вот такой запрос — я отвечу вот так». И этот договор должен быть одинаково понятным и в happy-path, и в ситуации «ой». Если сервер ограничивается «ну вот тебе 404 и иди сам думай», клиенту приходится угадывать: это «не найдено», «не туда постучался», «у вас формат неправильный», или «сервер устал и ушёл в отпуск».

В реальности клиентский код редко выглядит как человек с браузером, который читает «Not Found» и грустит. Чаще это мобильное приложение, фронтенд, интеграция, тесты, мониторинг. Им нужно не художественное описание страданий, а структурированный ответ: что случилось, где случилось и что можно с этим сделать. Поэтому наша задача — построить минимальный, но самодостаточный каркас ошибки, который всегда одинаковый по форме.

И тут важно поймать простую мысль: HTTP-статус — это только вершина айсберга. Он говорит «класс проблемы», но не описывает конкретику. А ошибка без конкретики — это как баг-репорт «не работает» без шагов воспроизведения. Формально не врёте, но толку… как от кнопки «Сделать хорошо».

2. ProblemDetail в Spring MVC

ProblemDetail — это стандартная идея: описывать ошибку не одним текстом, а небольшим JSON-объектом с фиксированными полями. В Spring MVC эта идея материализуется в классе ProblemDetail. Мы не изобретаем «свой собственный формат ошибок с полем message и success=false», а используем понятный каркас, который многие клиенты уже умеют обрабатывать (или хотя бы легко научить).

Если представить обработку запроса как конвейер, то при ошибке он выглядит примерно так: контроллер или сервис бросает исключение, @ControllerAdvice его ловит, превращает в ProblemDetail, а дальше Spring MVC сериализует этот объект в JSON и выставляет Content-Type: application/problem+json. Никакой магии — просто нормальная, взрослая дисциплина обработки негативных сценариев.

flowchart TD
    A[HTTP request] --> B[Controller]
    B --> C[Service]
    C --> D{Ошибка?}
    D -->|нет| E[Response DTO]
    D -->|да| F[Exception]
    F --> G["@ControllerAdvice / @ExceptionHandler"]
    G --> H[ProblemDetail]
    H --> I[HTTP response: application/problem+json]

Важный нюанс для проекта: Spring Boot умеет работать с Problem Details «из коробки», но вы должны быть последовательны. Если часть ошибок вы возвращаете как ProblemDetail, а часть — как «случайный Map», клиенту будет больно. Ему придётся писать ветвления по формату ответа, и это уже не REST API, а квест «угадай JSON».

Ещё одна практическая деталь: даже если клиент «и так знает, какой URL он вызвал», поле instance в ответе делает ошибку самодостаточной. Это особенно заметно, когда ответы логируются, улетают в трассировку, тесты падают на CI, и вы смотрите на логи без полного контекста. Ошибка, которая несёт в себе минимальные данные о запросе, экономит время на расследование.

3. Минимальный каркас ProblemDetail

Сейчас мы зафиксируем то, что должно быть в каждом error response, если мы хотим называть API «production-like» хотя бы по манерам. У ProblemDetail есть стандартные поля, и именно они — наш минимальный «скелет». Всё дополнительное (code, fieldErrors, timestamp, requestId) — это уже надстройки, которые мы будем аккуратно добавлять позже, не разрушая базовую форму.

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

Поле Вопрос, на который отвечает Что важно помнить Пример значения
type «Какой это класс проблемы?» Это идентификатор типа проблемы (URI), а не текст ошибки /problems/task-not-found
title «Как это коротко назвать?» Короткое и стабильное название для человека Task not found
status «Какой HTTP-статус у ошибки?» В теле дублируем статус, чтобы ответ был самодостаточным 404
detail «Что конкретно случилось в этом запросе?» Здесь уместна конкретика: id, текущее состояние, значение параметра Task with id ... was not found
instance «К какому запросу это относится?» URI запроса (или его path), чтобы привязать ошибку к конкретному вызову /api/v1/tasks/...

type: идентификатор класса проблемы

Поле type часто воспринимают как «ну давайте туда положим что-нибудь». А потом туда кладут текст сообщения, и смысл стандарта ломается. На самом деле type — это идентификатор типа проблемы, который должен быть стабильным. Он не про конкретный случай, а про категорию: “task not found”, “malformed json”, “unsupported media type”.

Формально type — это URI (в стандарте допускается и относительный URI). В идеальном мире это ссылка на страницу документации, где описано, что это за ошибка и как её обрабатывать. В учебном проекте мы не будем поднимать «портал документации ошибок» (иначе курс превратится в “Spring + static hosting”), но мы всё равно можем задавать type как стабильный идентификатор, например /problems/task-not-found.

Практический критерий хороший: если вы поменяете текст detail (потому что захотели улучшить формулировку), type должен остаться прежним. Это «имя папки», а detail — «имя файла внутри папки». Если вы делаете наоборот — вы вносите хаос.

Ещё один важный момент: не подменяйте type вашим application-specific code. type — часть стандарта Problem Details и обычно выражает «тип проблемы» в терминах URI. code — это внутренняя, машиночитаемая константа приложения. Мы к ней придём дальше, но сейчас важно не смешать роли, чтобы не получилось два поля «про одно и то же», но по-разному.

title: короткое имя проблемы

title — это не место для романа. Это короткая подпись, которую удобно показать человеку (или написать в лог), чтобы сразу понять, о чём речь. Она должна быть достаточно стабильной: если сегодня вы возвращаете title = "Task not found", а завтра title = "No such task", клиенту сложно строить любые пользовательские сообщения или поиск по логам. Да, клиент не должен парсить title как код, но стабильность всё равно полезна.

Хорошая практика: title — это «заголовок категории», а detail — «описание конкретного случая». Поэтому title обычно не содержит ID, не содержит текущий статус, не содержит параметры запроса. Максимум — аккуратная формулировка уровня “Task not found”, “Invalid input”, “Unsupported media type”.

Есть ещё одна человеческая причина: title — это то, что вы увидите в Swagger UI, в Postman, в тестах. Если туда писать всё подряд, через неделю вы сами перестанете понимать, где какая ошибка. Стабильный title делает диагностику проще. И да, это тот редкий случай, когда дисциплина — не скука, а экономия времени.

status: статус и в HTTP, и в теле

На первый взгляд дублирование статуса выглядит странно: «Но ведь статус уже есть в HTTP response!». Да, есть. Но в контрактном мышлении это не «лишние байты», а самодостаточность. Ошибки часто живут дольше, чем один конкретный HTTP-ответ: их логируют, пересылают, сохраняют в отчёты, анализируют в тестах. И там не всегда удобно «доставать статус из метаданных» — иногда вы видите только JSON.

Кроме того, наличие status в теле помогает быстро проверить, что ваш @ControllerAdvice не ошибся. Это как ремень безопасности: вроде бы и так аккуратно ездите, но хорошо, что есть. Если вы случайно вернули HTTP 404, а в теле status: 500, вы сразу заметите несостыковку. А несостыковка статуса — это прямой путь к недоверию клиента: он уже не знает, чему верить.

В Spring ProblemDetail.forStatusAndDetail(...) обычно выставляет статус автоматически. Это удобно, но не освобождает от ответственности. Нужно следить, чтобы реальный HTTP-статус ответа совпадал с тем, что вы сериализуете. Иначе ваш API начинает говорить разными голосами одновременно — как чат, в котором один пишет «всё ок», а другой рядом кричит «мы все умрём».

detail: конкретика текущего случая

detail — это место, где вы описываете конкретный инцидент. Если title — это «как называется проблема», то detail — «что именно случилось прямо сейчас». Именно сюда нормально включать taskId, значение неверного параметра, текущий статус ресурса, ожидаемый формат, и т.д. Но тут важно не перегнуть палку и не начать утекать внутренними подробностями.

Хорошая формула для detail: текст должен быть понятен внешнему клиенту, который не знает ваших Java-классов, ваших пакетов и того, как называется ваш repository. Сообщение "com.example.tasktracker.TaskServiceImpl threw NullPointerException" — это отличный способ рассказать миру, что у вас внутри происходит, и одновременно отличный способ сделать клиента беспомощным: ему всё равно непонятно, что делать.

Ещё один нюанс: detail — не обязан быть суперстабильным. Он может меняться от запроса к запросу, потому что в нём появляются разные ID и разные значения. Это нормально. Стабильность мы обеспечим позже через code, а сейчас достаточно понимать: detail — это полезная конкретика для человека и для быстрой диагностики.

instance: привязка к запросу

instance — это URI, который идентифицирует конкретный случай проблемы. Проще всего в REST API трактовать его как путь запроса, например /api/v1/tasks/{taskId}. Благодаря этому клиент и вы сами (в логах) видите, к какому endpoint относится ошибка. И это помогает даже тогда, когда вы по ошибке отправили запрос не туда или когда один и тот же code/title может возникнуть в разных местах.

В нашем учебном проекте instance часто закрывает потребность в отдельном поле path. Да, можно добавить path как extension field, но это лишнее дублирование, если instance уже показывает URI запроса. Чем меньше дублирующих полей, тем проще поддерживать контракт: меньше мест, где можно случайно разъехаться.

Практический совет: instance должен быть максимально близок к реальному URI запроса. Если вы руками собираете строку и ошиблись (например, забыли /api/v1 или перепутали taskId), вы получите «ошибку про ошибку» — в ответе будет неверная ссылка на запрос. Поэтому в реальном коде часто берут URI из запроса (например, через HttpServletRequest). Мы пока покажем упрощённый, учебный вариант — руками, но с правильной идеей.

4. Примеры и ограничения в Task Tracker API

Минимальная реализация: TaskNotFoundExceptionProblemDetail

Сейчас соберём минимальный пример на нашем проекте Task Tracker API, не залезая в дополнительные поля и не усложняя модель. У нас уже есть глобальный слой обработки ошибок на базе @ControllerAdvice. Мы добавим (или уточним) обработчик TaskNotFoundException, который возвращает ProblemDetail с заполненными type, title, status, detail, instance.

Ниже — именно минимальный base-set: только стандартные поля ProblemDetail. Сверху на него можно навесить code, fieldErrors и диагностические поля, но сам каркас type/title/status/detail/instance отсюда уже не меняется.

Начнём с доменного исключения. Оно живёт в com.example.tasktracker.domain.exception и должно уметь сообщить taskId, чтобы @ControllerAdvice мог собрать человекочитаемый detail. Обратите внимание: мы храним taskId отдельным полем, а не пытаемся парсить его из текста сообщения (парсить строки — это всегда путь страдания).

public class TaskNotFoundException extends RuntimeException {

    // Идентификатор задачи, которую не нашли — нужен для формирования ProblemDetail.detail
    private final String taskId;

    public TaskNotFoundException(String taskId) {
        // Храним id отдельным полем, чтобы не парсить его из текста сообщения
        this.taskId = taskId;
    }

    public String getTaskId() {
        // Достаём id, чтобы обработчик ошибок мог собрать человекочитаемую детализацию
        return taskId;
    }
}

Теперь обработчик в нашем GlobalExceptionHandler (он у нас в web-layer, например в пакете com.example.tasktracker.api.error). Здесь мы создаём ProblemDetail и заполняем поля. status и detail выставляются через forStatusAndDetail, а остальные — вручную, чтобы каркас был полностью определён.

@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handleTaskNotFound(TaskNotFoundException ex) {
    // Базу (status + detail) удобно выставлять через фабрику ProblemDetail
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND,
            "Task with id " + ex.getTaskId() + " was not found"
    );

    // type — стабильный идентификатор класса проблемы
    pd.setType(URI.create("/problems/task-not-found"));

    // title — короткое, стабильное имя для человека
    pd.setTitle("Task not found");

    // instance — привязка к конкретному запросу/ресурсу
    pd.setInstance(URI.create("/api/v1/tasks/" + ex.getTaskId()));

    return pd;
}

Если теперь клиент вызовет GET /api/v1/tasks/{taskId} с несуществующим id, он получит ответ примерно такой формы (упрощённый пример JSON). И это уже выглядит как договор: понятно, что случилось, к какому запросу относится, какой статус, какой тип проблемы.

{
  "type": "/problems/task-not-found",
  "title": "Task not found",
  "status": 404,
  "detail": "Task with id 7f3c2e52-1a1b-4f03-9c3d-2c9d8a8a8e12 was not found",
  "instance": "/api/v1/tasks/7f3c2e52-1a1b-4f03-9c3d-2c9d8a8a8e12"
}

И ещё один маленький, но важный штрих: клиент должен видеть, что это именно problem details. То есть Content-Type ответа будет application/problem+json. Spring MVC умеет это делать, если вы идёте по правильному пути ProblemDetail и включили поддержку problem details в конфигурации проекта.

Один каркас для 404, 400 и 409

Сейчас хочется, конечно, сразу сделать «идеальную модель ошибок на все случаи жизни». Но сегодняшняя цель скромнее и важнее: убедиться, что базовый каркас одинаково подходит для разных типов проблем. И 404 Not Found, и 400 Bad Request, и 409 Conflict — это разные смыслы, но форма ответа должна оставаться узнаваемой. Клиент должен «узнать» ошибку по форме так же легко, как вы узнаёте JSON-ответ списка по полю items.

То есть меняются значения и дополнительные extension fields, а базовый набор type/title/status/detail/instance остаётся тем же. Например, тот же самый каркас можно использовать и для конфликта. Мы пока не разбираем, когда именно нужен 409, но показываем, что структура не меняется: меняются только status, type, title, detail, instance.

// Пример: конфликт бизнес-состояния, когда операция недопустима в текущем статусе задачи
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
        HttpStatus.CONFLICT,
        "Current task state does not allow this operation"
);

// Классифицируем проблему стабильным type, чтобы её можно было группировать
pd.setType(URI.create("/problems/state-conflict"));
pd.setTitle("State conflict");

// Привязываем ошибку к конкретному ресурсу/запросу
pd.setInstance(URI.create("/api/v1/tasks/42"));

И то же самое для 400, когда запрос в целом «не той формы» (например, тело запроса не проходит базовую проверку). Опять же, без углубления в fieldErrors — только каркас.

// Пример: запрос невалидный по форме (например, не проходит базовую валидацию)
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST,
        "Request body is invalid"
);

// type и title держим стабильными, чтобы клиенту было проще понимать категорию ошибки
pd.setType(URI.create("/problems/invalid-input"));
pd.setTitle("Invalid input");

// instance помогает понять, к какому endpoint относится ошибка (особенно в логах/тестах)
pd.setInstance(URI.create("/api/v1/tasks"));

Главное, что нужно почувствовать: мы не делаем отдельный «формат 404», отдельный «формат 409» и третий «формат 400». Мы делаем один язык ошибок, где меняется содержание, но не грамматика.

Что не должно попасть в ProblemDetail

Даже самый красивый ProblemDetail можно испортить одной привычкой: «а давайте просто отдадим наружу ex.getMessage()». Иногда это безопасно (если исключение ваше, и сообщение заранее подготовлено), но часто это приводит к утечке внутренних деталей. И это не про паранойю, это про контракт: клиент не должен зависеть от ваших внутренних текстов и точно не должен видеть технические названия классов и стек вызовов.

Если вы отдаёте stack trace, вы одновременно делаете две вещи. Первая — выдаёте потенциально чувствительную информацию о системе. Вторая — делаете клиента зависимым от случайного текста: завтра вы чуть изменили сообщение в исключении, и вдруг фронтенд-тесты начали падать, потому что кто-то (не вы, конечно, а «кто-то») сравнивал строки. Это как построить интеграцию на цвете кнопки в чужом приложении: пока кнопка синяя — вы счастливы, как только она стала зелёной — всё развалилось.

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

5. Типичные ошибки при сборке ProblemDetail

В этом месте обычно хочется сказать «ну тут всё просто». И да, механически — просто. Но именно простые вещи чаще всего и ломают контракт: не потому что сложно, а потому что «да ладно, потом поправим». В ошибках API «потом» быстро превращается в «никогда», потому что клиенты уже привыкли к кривому формату, а менять его становится больно.

Ошибка №1: путать title и detail.
Очень распространённая картина: в title кладут «Task with id ... not found», а detail делают общим «Not found». В итоге заголовок каждый раз разный, его нельзя использовать как стабильную подпись, а detail теряет смысл конкретики. Лечится просто: title короткий и общий, detail конкретный и «про этот случай».

Ошибка №2: не заполнять instance, потому что «клиент и так знает URL».
Да, клиент знает. Но когда ошибка попадает в логи, в баг-репорт, в отчёт тестов, в алерт мониторинга — там URI может быть потерян или неочевиден. instance делает ошибку самодостаточной. Это недорогая привычка, которая экономит много времени.

Ошибка №3: оставлять type по умолчанию и не думать о классификации ошибок.
Если type всегда одинаковый или не задаётся, вы теряете возможность по-человечески группировать ошибки. Даже если вы не делаете страницу документации по type, сам факт наличия стабильных type уже помогает: и в логах, и в тестах, и в будущей документации.

Ошибка №4: пытаться запихнуть в detail сырое сообщение исключения, особенно фреймворкового.
Сообщения фреймворка могут быть длинными, техническими и нестабильными между версиями. Сегодня одно, завтра другое — и вы сами же потом будете удивляться, почему у вас «сломался контракт ошибок» после обновления зависимостей. detail должен быть вашим, внешним и контролируемым.

Ошибка №5: делать разные формы ошибок для разных контроллеров.
Один контроллер отдаёт ProblemDetail, другой — { "error": "..." }, третий — вообще строку. Клиенту остаётся только плакать и писать три парсера. Единственный нормальный выход — централизовать обработку и держать каркас одинаковым везде. Тогда API действительно становится предсказуемым.

1
Задача
Spring REST & MVC, 21 уровень, 0 лекция
Недоступна
404 для отсутствующей книги с минимальным ProblemDetail
404 для отсутствующей книги с минимальным ProblemDetail
1
Задача
Spring REST & MVC, 21 уровень, 0 лекция
Недоступна
Ошибка типа query-параметра в едином каркасе ProblemDetail
Ошибка типа query-параметра в едином каркасе ProblemDetail
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ