JavaRush /Курсы /Java Server /Единая JSON-ошибка: ErrorR...

Единая JSON-ошибка: ErrorResponse

Java Server
19 уровень , 3 лекция
Открыта

1. Единый формат ошибки как API-контракт

Если вы раньше писали консольные программы, у вас был один честный способ общения с миром: System.out.println("Ой, ошибка"). В HTTP API такой фокус не работает. Тут у вас есть клиент (Postman, браузер, другой сервис), который хочет понять, что произошло, и желательно понять это одинаково во всех endpoint-ах. И вот тут начинаются проблемы: если ошибки возвращаются как попало, клиентская сторона превращается в «археолога по чужим ответам».

Представьте, что вы тестируете API в Postman. Для успешного ответа вы уже привыкли: приходит JSON определённой формы. А теперь ошибка. Сегодня она приходит так:

"Something went wrong"

Завтра так:

{ "error": "bad id" }

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

Единая форма ошибки решает сразу две прикладные задачи. Во-первых, клиент может машинно понять тип проблемы по стабильному коду ошибки (в нашем случае — errorCode), не пытаясь парсить человеческие фразы. Во-вторых, человек (вы, ваш одногруппник, будущий фронтендер) может прочитать message и быстро понять, что не так, без необходимости открывать логи и угадывать смысл по статусу.

И здесь важно не перепутать роли. HTTP-статус отвечает на вопрос «какого класса проблема» (ошибка запроса? не найдено? конфликт?), а тело ошибки отвечает на вопрос «что конкретно пошло не так в рамках нашего API». Эти две части не дублируют, а дополняют друг друга: статус — уровень “категории”, JSON — уровень “деталей”.

До этого момента мы собирали happy-path API: request DTO приходит снаружи, mapping переводит его в домен, а наружу уходит success response. Но на любом из этих шагов может сработать unhappy-path: сломанный JSON, невалидные данные, отсутствие ресурса, внутренний сбой. На границе API всё это должно превратиться не в случайный текст, а в предсказуемый error-контракт: web-слой позже выберет подходящий статус, а тело ошибки у нас уже должно быть единым. Поэтому сначала зафиксируем форму такого error-body.

2. Минимальный дизайн ErrorResponse для ReadLater

Сейчас у нас цель очень «прагматичная»: придумать такую модель ошибки, чтобы её было легко возвращать из любого endpoint-а, легко сериализовать в JSON и легко читать глазами. При этом мы не хотим превращать проект в маленький фреймворк «как у взрослых» — мы делаем bridge-course, и нам важно, чтобы решение было понятным первокурснику… даже если он пришёл на курс случайно и теперь притворяется, что всё понимает.

В ReadLater Starter мы фиксируем три поля:

  • errorCode — короткий стабильный код проблемы. Он нужен прежде всего для кода клиента, потому что код не любит поэзию и метафоры, а любит предсказуемые значения.
  • message — человекочитаемое сообщение, которое можно показать в Postman, в консоли, в минимальном UI. Это не технический лог и не stack trace.
  • details — дополнительные подробности, но в предсказуемой структуре. Очень важно, что detailsсписок, а не строка: сегодня у вас одна проблема (“id не число”), завтра — три (“title пустой”, “author пустой”, “status неизвестный”). Тип оставляем одинаковым.

Давайте зафиксируем DTO в коде. Здесь удобно использовать record: это чистая форма данных, без поведения, без сеттеров, без драматических монологов.

package com.example.readlater.common.error;

import java.util.List;

// DTO для единого формата ошибок в API.
// Важно: это именно "данные", без логики и без сеттеров.
public record ErrorResponse(
        // Стабильный код ошибки: по нему клиент может делать switch/if.
        String errorCode,
        // Короткое сообщение для человека (Postman/UI), без stack trace.
        String message,
        // Дополнительные уточнения. Всегда список (даже если пусто).
        List<String> details
) {}

Обратите внимание на несколько “маленьких” решений, которые на деле экономят нервы. Мы не делаем details nullable и не пытаемся возвращать “то список, то null”. Если деталей нет — это просто пустой список. Клиенту приятно: тип всегда один и тот же.

Вот как примерно будет выглядеть JSON, который мы хотим видеть в любой ошибке:

{
  "errorCode": "INVALID_REQUEST",
  "message": "Запрос содержит некорректные данные",
  "details": ["id must be a number"]
}

И даже если деталей нет, форма должна остаться такой же:

{
  "errorCode": "INTERNAL_ERROR",
  "message": "Внутренняя ошибка приложения",
  "details": []
}

Никаких “то поле есть, то поля нет”. Контракт любит стабильность. А нестабильность любит только хаос (и, возможно, котики, но котики хотя бы милые).

3. Поля errorCode, message, details: назначение

Сейчас легко сделать типичную ошибку новичка: перепутать предназначение полей и начать лепить в errorCode человеческий текст, а в message — технические детали исключения. Поэтому мы аккуратно разберём каждое поле: что туда класть, что туда не класть, и почему это важно. Думайте об этом как о договоре между вами (сервером) и клиентом: если вы договор нарушили, клиент может “обидеться” и начать падать.

Для наглядности — маленькая табличка смыслов:

Поле Для кого в первую очередь Требования к форме Пример
errorCode для кода клиента коротко, стабильно, без пробелов INVALID_REQUEST
message для человека кратко, без лишней техничности Запрос содержит некорректные данные
details для человека и дебага список строк, без stack trace ["title must not be blank"]

Дальше — подробности.

Про errorCode.
Это поле нельзя делать “креативным”. Оно должно быть скучным, как бухгалтерия, и именно поэтому полезным. Хорошая практика — использовать UPPER_SNAKE_CASE, без пробелов и без “красивых фраз”. Почему? Потому что errorCode часто превращается в switch на стороне клиента, в аналитику, в правила обработки. И если вы сегодня вернули "invalid_request", а завтра "INVALID REQUEST" (с пробелом), вы устроили клиенту маленький праздник боли.

Чтобы не плодить “магические строки” в Java-коде, удобно держать набор кодов в enum. В JSON всё равно уйдёт строка, но в коде вы перестанете ошибаться в одной букве (да, ошибка на одну букву — самый популярный баг после NullPointerException).

package com.example.readlater.common.error;

// Перечень стабильных кодов ошибок.
// Эти значения — часть API-контракта, менять их "по настроению" нельзя.
public enum ErrorCode {
    INVALID_REQUEST,
    READING_ITEM_NOT_FOUND,
    EXTERNAL_ID_CONFLICT,
    METHOD_NOT_ALLOWED,
    INTERNAL_ERROR
}

А когда вы создаёте ErrorResponse, вы берёте code.name():

import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;

import java.util.List;

// Пример: создаём ответ об ошибке из enum-кода.
// code.name() гарантирует "ровно такое же" значение, как в enum.
ErrorResponse err = new ErrorResponse(
        ErrorCode.INVALID_REQUEST.name(),
        "Запрос содержит некорректные данные",
        // Детали — всегда список строк: даже если 1 элемент.
        List.of("id must be a number")
);

Так вы сохраняете стабильность и защищаетесь от опечаток.

Про message.
Это сообщение не должно быть “логом” и не должно быть “исключением”. Оно должно быть коротким и человеческим. Можно представлять, что его читает не только разработчик, но и условный пользователь (или будущий UI), который увидит “Элемент списка чтения не найден”, а не “java.lang.IllegalStateException: reading item absent”.

Почему важно не возвращать технический текст исключения? Потому что исключение пишется для разработчика, а не для внешнего мира. Там может оказаться всё: внутренние имена классов, куски путей файлов, намёки на архитектуру. Для учебного проекта это не “секретность”, а дисциплина: мы отделяем user-facing контракт от внутренних деталей.

Про details.
Это поле часто хочется сделать “как получится”. Но если сделать как получится, оно станет бесполезным. Хорошая стратегия — хранить там короткие уточнения, которые помогают понять проблему: какое поле, какое значение, какая причина. Детали могут быть на английском (короче и привычнее для технических сообщений) или на русском — главное, чтобы было единообразно.

Например, детали для ошибки могут выглядеть так:

import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;

import java.util.List;

// Пример: несколько ошибок валидации в одном ответе.
ErrorResponse err = new ErrorResponse(
        ErrorCode.INVALID_REQUEST.name(),
        "Запрос содержит некорректные данные",
        List.of(
                // Каждая строка — отдельная "подробность" по причине ошибки.
                "title must not be blank",
                "status must not be null"
        )
);

Тут важная мысль: details — это не место для stack trace. Если вы положите туда 200 строк, клиент вас не поблагодарит, а вы сами потом будете читать это в Postman и думать: «Зачем я это сделал и кто меня обидел?». Stack trace — для логов, а не для ответа клиенту.

4. Где держать ErrorResponse и как создавать ошибки

На этом этапе курса мы уже договорились про структуру проекта: app, config, common, catalog, readinglist. Ошибки локального API — это общая часть будущего серверного режима: они понадобятся и /health, и reading-list endpoint-ам. Поэтому логично вынести модель ErrorResponse в common.error, чтобы не плодить её копии в каждом feature и не получать кучу “почти одинаковых” классов.

Но здесь есть ловушка. Папка common очень легко превращается в “кладбище полезных утилит”, куда складывают всё подряд: StringUtils, DateUtils, SuperHelperFinal2. В итоге проект становится похож на ящик в кухне, куда вы кидаете всё, что лень раскладывать по полкам. Поэтому мы делаем минимум: DTO + маленький helper, если он действительно убирает копипасту.

Если не хочется руками собирать один и тот же ErrorResponse, поверх канонических ErrorResponse и ErrorCode можно добавить маленький helper. Он не создаёт новую модель ошибок и не меняет контракт — только убирает повторяющийся код.

package com.example.readlater.common.error;

import java.util.List;

// Небольшой helper поверх канонических ErrorResponse + ErrorCode.
// Цель — убрать копипасту, а не придумать новую "систему ошибок".
public final class ErrorResponses {
    private ErrorResponses() {
        // Запрещаем создавать экземпляры: используем только static-методы.
    }

    public static ErrorResponse internalError() {
        return new ErrorResponse(
                ErrorCode.INTERNAL_ERROR.name(),
                "Внутренняя ошибка приложения",
                List.of()
        );
    }

    public static ErrorResponse invalidRequest(String message, String... details) {
        return new ErrorResponse(
                ErrorCode.INVALID_REQUEST.name(),
                message,
                List.of(details)
        );
    }
}

Если helper не нужен, ничего страшного: руками создать new ErrorResponse(...) тоже нормально. Базой всё равно остаются ErrorResponse и ErrorCode.

5. Мини-проверка: JSON ошибки без запуска сервера

Сейчас нам не нужен уже запущенный HttpServer, чтобы проверить форму ошибки. Достаточно убедиться, что ErrorResponse нормально превращается в JSON и действительно выглядит так, как мы договорились. Это снимает кучу вопросов вида “почему у меня JSON вдруг не такой”.

Ниже — мини-пример, который можно выполнить где угодно (хоть в маленьком тестовом main()), просто чтобы увидеть результат.

import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import tools.jackson.databind.ObjectMapper;

import java.util.List;

ObjectMapper mapper = new ObjectMapper();

var err = new ErrorResponse(
        ErrorCode.INVALID_REQUEST.name(),
        "Запрос содержит некорректные данные",
        List.of("id must be a number")
);

System.out.println(mapper.writeValueAsString(err));
// {"errorCode":"INVALID_REQUEST","message":"Запрос содержит некорректные данные","details":["id must be a number"]}

Заметьте, что имена полей record (errorCode, message, details) становятся именами полей JSON. Это ровно то, что нам нужно: контракт фиксируется прямо в коде DTO, и не приходится вручную писать маппинг “как назвать поле”.

Ещё один маленький пример для “ошибка без деталей”. Мы хотим убедиться, что details остаётся массивом, а не превращается то в null, то в отсутствие поля.

import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import tools.jackson.databind.ObjectMapper;

import java.util.List;

ObjectMapper mapper = new ObjectMapper();

var err = new ErrorResponse(
        ErrorCode.INTERNAL_ERROR.name(),
        "Внутренняя ошибка приложения",
        List.of()
);

System.out.println(mapper.writeValueAsString(err));
// {"errorCode":"INTERNAL_ERROR","message":"Внутренняя ошибка приложения","details":[]}

Это маленькая проверка, но она важная: когда позже вы будете отдавать такие ответы клиенту, вы не хотите сюрпризов в форме JSON.

6. Типичные ошибки

Ошибка №1: разные структуры ошибок в разных местах.
Очень легко “на скорую руку” вернуть строку "bad request" в одном endpoint-е и объект { "error": "..." } в другом. А потом внезапно выясняется, что клиентскую обработку невозможно написать нормально: нужно проверять то одно, то другое. Единый ErrorResponse — это как единый формат чеков: да, скучно, зато работает.

Ошибка №2: details то строка, то список, то null.
Когда тип поля “прыгает”, JSON перестаёт быть контрактом. Сегодня клиент ждёт массив, завтра получает строку и падает при десериализации. Если деталей нет — возвращайте пустой список. Если одна деталь — список из одного элемента. Это та самая дисциплина, которая кажется занудной ровно до первого бага в интеграции.

Ошибка №3: в message или details попадает текст исключения или stack trace.
У новичка есть соблазн: “поймал исключение — вернул e.getMessage() клиенту”. На практике это или бесполезно (“For input string: 'abc'”), или слишком технически, или даже опасно (в реальных проектах). В ответ клиенту лучше отдавать нейтральное сообщение, а подробности — в лог.

Ошибка №4: errorCode превращается в “сообщение для человека”.
Если errorCode начинает выглядеть как "Запрос плохой потому что id не число", вы потеряли смысл. Код должен быть коротким и стабильным. Человеческие объяснения живут в message и details. Клиентский код не должен парсить русские предложения (он и так страдает, не усугубляйте).

Ошибка №5: errorCode меняется от случая к случаю без причин.
Сегодня вы вернули "INVALID_REQUEST_ID", завтра "INVALID_ID", послезавтра "BAD_ID". Для человека это почти одно и то же, а для клиента — три разные ошибки. Лучше иметь один код на класс проблем и уточнять причину через детали. Тогда поведение API будет предсказуемым, а не “в зависимости от настроения автора”.

1
Задача
Java Server, 19 уровень, 3 лекция
Недоступна
Единый JSON bad request
Единый JSON bad request
1
Задача
Java Server, 19 уровень, 3 лекция
Недоступна
internalError с пустым списком details
internalError с пустым списком details
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ