Как делать12 октября 2026 г.6 мин чтения
JavaШаг 6 из 6

Рецепт: единый формат ошибок в Spring Boot

Коротко

Единый формат ошибок в Spring Boot делается одним классом: @RestControllerAdvice, унаследованный от ResponseEntityExceptionHandler, возвращает ProblemDetail по стандарту RFC 9457 для всех случаев — ошибок валидации, 404, 409 и непредвиденных 500. Клиент всегда получает application/problem+json с понятным detail, а стектрейс остаётся в логе и связывается с ответом через errorId.

Рецепт кодаУровень: СреднийРецепты Java / SpringПрактика: ~20–40 мин

После материала вы сможете

  • подключить GlobalExceptionHandler, который отдаёт все ошибки API в формате application/problem+json
  • вернуть ошибки полей тела и параметров запроса списком errors с указанием, где ошибка
  • отвечать на непредвиденные сбои кодом 500 без стектрейса и находить их в логе по errorId
  • проверить формат ответов тестами MockMvc в срезе @WebMvcTest

Проверено на

Spring Boot 4.1.1Spring Framework 7.0.9Java 25 LTS (Eclipse Temurin 25.0.4)Hibernate Validator 9.1.3Maven 3.10.0

Сверено с документацией: 12 октября 2026 г.

Содержание
  1. 01Задача: ошибки API в одном формате
  2. 02Готовый код: исключения и GlobalExceptionHandler
  3. 03Как подключить
  4. 04Как проверить: тесты MockMvc и ответы
  5. 05Что не отдавать клиенту и ограничения
  6. 06Ссылки и следующий шаг

Задача: ошибки API в одном формате

Без общего обработчика каждая ошибка выглядит по-своему. Исключение из сервиса превращается в 500 со стандартным телом Spring Boot, ошибка валидации — в 400 со своими полями, а try/catch в контроллере возвращает строку. Фронтенду и интеграциям приходится разбирать несколько форматов, а случайный ex.getMessage() в ответе показывает адрес базы данных или фрагмент SQL.

Рецепт решает это одним классом. Все ответы с ошибкой получают тип application/problem+json и поля стандарта RFC 9457 «Problem Details for HTTP APIs»: type, title, status, detail, instance. В Spring Framework стандарт представлен классом ProblemDetail, а базовый класс ResponseEntityExceptionHandler уже превращает в него встроенные ошибки Spring MVC. Что означает каждое поле и когда заводить свой type, разобрано в уроке о проектировании API.

Неверные поля тела запроса

Исключение
MethodArgumentNotValidException
Ответ
400, type …/validation-error, список errors с pointer

Нарушено ограничение параметра (@Max на limit)

Исключение
HandlerMethodValidationException
Ответ
400, тот же type, в errors — имя параметра

Записи нет

Исключение
NotFoundException (своё)
Ответ
404, поля type нет — значит about:blank

Конфликт с данными: артикул занят

Исключение
ConflictException (своё)
Ответ
409, свой type …/duplicate-sku

Битый JSON, неверный метод, неизвестный адрес

Исключение
встроенные исключения Spring MVC
Ответ
400, 405, 404 — обрабатывает базовый класс

Всё остальное

Исключение
любое Exception
Ответ
500, общий текст и errorId; стектрейс — только в лог

Готовый код: исключения и GlobalExceptionHandler

Сначала два доменных исключения. Сервис бросает их, ничего не зная об HTTP: бизнес-логика не зависит от веб-слоя, а код ответа выбирается в одном месте.

src/main/java/com/example/shop/error/NotFoundException.java
package com.example.shop.error;

// ресурса нет: обработчик превратит исключение в 404
public class NotFoundException extends RuntimeException {

    public NotFoundException(String message) {
        super(message);
    }
}
src/main/java/com/example/shop/error/ConflictException.java
package com.example.shop.error;

// запрос противоречит текущему состоянию данных: 409
// type — короткое имя типа проблемы, из него собирается URI документации
public class ConflictException extends RuntimeException {

    private final String type;
    private final String title;

    public ConflictException(String type, String title, String detail) {
        super(detail);
        this.type = type;
        this.title = title;
    }

    public String getType() {
        return type;
    }

    public String getTitle() {
        return title;
    }
}

Теперь сам обработчик. Его место — в пакете внутри пакета приложения: тогда @SpringBootApplication найдёт класс сканированием компонентов и применит ко всем контроллерам.

src/main/java/com/example/shop/error/GlobalExceptionHandler.java
package com.example.shop.error;

import jakarta.servlet.http.HttpServletRequest;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

// Все ошибки API — в формате RFC 9457 (application/problem+json).
// Базовый класс уже обрабатывает встроенные ошибки Spring MVC:
// битый JSON — 400, неверный метод — 405, неизвестный адрес — 404 и т. д.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // по этому адресу должна лежать документация типов проблем
    static final String PROBLEMS = "https://api.example.com/problems/";

    @ExceptionHandler(NotFoundException.class)
    public ProblemDetail handleNotFound(NotFoundException ex) {
        // type не задан — по RFC это about:blank, title станет «Not Found»
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(ConflictException.class)
    public ProblemDetail handleConflict(ConflictException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        problem.setType(URI.create(PROBLEMS + ex.getType()));
        problem.setTitle(ex.getTitle());
        return problem;
    }

    // Всё непредвиденное — 500 без подробностей. Стектрейс уходит только в лог,
    // а клиент получает errorId, по которому запись в логе легко найти
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
        String errorId = UUID.randomUUID().toString();
        log.error("Необработанная ошибка {} в {} {}", errorId, request.getMethod(), request.getRequestURI(), ex);
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
                "Внутренняя ошибка сервера. Сообщите в поддержку код " + errorId);
        problem.setProperty("errorId", errorId);
        return problem;
    }

    // @Valid @RequestBody: ошибки полей тела запроса
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        List<Map<String, String>> errors = new ArrayList<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.add(Map.of("pointer", toPointer(error.getField()), "detail", error.getDefaultMessage()));
        }
        return handleExceptionInternal(ex, validationProblem(status, errors), headers, status, request);
    }

    // ограничения прямо на параметрах метода: @RequestParam @Max(100) int limit
    @Override
    protected ResponseEntity<Object> handleHandlerMethodValidationException(
            HandlerMethodValidationException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        List<Map<String, String>> errors = new ArrayList<>();
        for (ParameterValidationResult result : ex.getParameterValidationResults()) {
            String name = result.getMethodParameter().getParameterName();
            for (MessageSourceResolvable error : result.getResolvableErrors()) {
                errors.add(Map.of("parameter", name, "detail", error.getDefaultMessage()));
            }
        }
        return handleExceptionInternal(ex, validationProblem(status, errors), headers, status, request);
    }

    private static ProblemDetail validationProblem(HttpStatusCode status, List<Map<String, String>> errors) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, "Проверьте параметры запроса");
        problem.setType(URI.create(PROBLEMS + "validation-error"));
        problem.setTitle("Ошибка валидации");
        problem.setProperty("errors", errors);
        return problem;
    }

    // items[0].name -> #/items/0/name (JSON Pointer, как в примере RFC 9457)
    private static String toPointer(String field) {
        return "#/" + field.replace("[", "/").replace("]", "").replace('.', '/');
    }
}
  • @RestControllerAdvice — это @ControllerAdvice плюс @ResponseBody: методы-обработчики действуют для всех контроллеров и пишут результат в тело ответа.
  • Наследование от ResponseEntityExceptionHandler даёт обработку всех встроенных исключений Spring MVC в формате RFC 9457. Методы handleMethodArgumentNotValid и handleHandlerMethodValidationException переопределены, чтобы добавить список errors.
  • Ошибки валидации приходят двумя исключениями: MethodArgumentNotValidException — для объекта с @Valid, HandlerMethodValidationException — когда ограничения стоят прямо на параметрах метода. Документация Spring советует обрабатывать оба.
  • Обработчик Exception.class не забирает встроенные ошибки: из нескольких подходящих методов Spring выбирает тот, чьё исключение ближе к брошенному по иерархии. Поэтому битый JSON по-прежнему получает 400 от базового класса, а не 500.
  • Поле instance Spring заполняет путём запроса сам, а type со значением about:blank в JSON не выводит: по RFC 9457 отсутствие поля и означает about:blank.
  • errorId и errors — расширения формата. RFC разрешает добавлять свои поля, а клиенты обязаны игнорировать незнакомые.

Как подключить

  1. Зависимости

    Нужны spring-boot-starter-webmvc и spring-boot-starter-validation, для тестов — spring-boot-starter-webmvc-test. Проект со start.spring.io с зависимостями web и validation уже содержит их.

  2. Скопируйте три класса

    NotFoundException, ConflictException и GlobalExceptionHandler — в пакет error. Замените https://api.example.com/problems/ на адрес, где будет документация ваших типов ошибок: RFC 9457 рекомендует, чтобы URI типа вёл на понятное человеку описание.

  3. Бросайте исключения из сервиса

    Вместо return null или ResponseEntity.notFound() в контроллере сервис бросает NotFoundException или ConflictException. Контроллер остаётся без try/catch.

  4. Перепишите тексты встроенных ошибок

    По умолчанию на неизвестный адрес Spring отвечает detail «No static resource api/unknown.» — лишняя подсказка о внутреннем устройстве. Тексты задаются в messages.properties по ключу problemDetail. плюс полное имя класса исключения.

  5. Свойство problemdetails не нужно

    spring.mvc.problemdetails.enabled=true включает встроенный обработчик Spring Boot, только если своего бина ResponseEntityExceptionHandler нет. С классом из рецепта свойство не требуется.

pom.xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<!-- … -->
<properties>
    <java.version>25</java.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
фрагмент pom.xml проекта со start.spring.io (web + validation)

Сервис и контроллер, на которых проверялся рецепт: хранилище в памяти, уникальный артикул и ограничение limit прямо на параметре метода.

src/main/java/com/example/shop/product/Product.java
package com.example.shop.product;

import java.math.BigDecimal;

public record Product(long id, String name, String sku, BigDecimal price) {
}
src/main/java/com/example/shop/product/ProductRequest.java
package com.example.shop.product;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;

public record ProductRequest(
        @NotBlank(message = "Укажите название")
        String name,

        @NotBlank(message = "Укажите артикул")
        @Pattern(regexp = "[A-Z0-9-]{3,20}", message = "Артикул — от 3 до 20 символов: A–Z, 0–9 и дефис")
        String sku,

        @NotNull(message = "Укажите цену")
        @Positive(message = "Цена должна быть больше нуля")
        BigDecimal price) {
}
src/main/java/com/example/shop/product/ProductService.java
package com.example.shop.product;

import com.example.shop.error.ConflictException;
import com.example.shop.error.NotFoundException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;

// хранилище в памяти — чтобы рецепт не зависел от базы данных
@Service
public class ProductService {

    private final Map<Long, Product> products = new ConcurrentHashMap<>();
    private final Map<String, Long> idsBySku = new ConcurrentHashMap<>();
    private final AtomicLong sequence = new AtomicLong();

    public List<Product> list(int limit) {
        return products.values().stream().limit(limit).toList();
    }

    public Product get(long id) {
        Product product = products.get(id);
        if (product == null) {
            throw new NotFoundException("Товар " + id + " не найден");
        }
        return product;
    }

    public Product create(ProductRequest request) {
        long id = sequence.incrementAndGet();
        if (idsBySku.putIfAbsent(request.sku(), id) != null) {
            throw new ConflictException("duplicate-sku", "Артикул уже занят",
                    "Товар с артикулом " + request.sku() + " уже есть");
        }
        Product product = new Product(id, request.name().strip(), request.sku(), request.price());
        products.put(id, product);
        return product;
    }
}
src/main/java/com/example/shop/product/ProductController.java
package com.example.shop.product;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

// в контроллере нет ни одного try/catch: ошибки разбирает GlobalExceptionHandler
@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService service;

    public ProductController(ProductService service) {
        this.service = service;
    }

    @GetMapping
    public List<Product> list(
            @RequestParam(defaultValue = "20")
            @Min(value = 1, message = "Не меньше 1")
            @Max(value = 100, message = "Не больше 100") int limit) {
        return service.list(limit);
    }

    @GetMapping("/{id}")
    public Product get(@PathVariable long id) {
        return service.get(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Product create(@Valid @RequestBody ProductRequest request) {
        return service.create(request);
    }
}
src/main/resources/messages.properties
# тексты для встроенных ошибок Spring MVC: не раскрываем внутреннее устройство
problemDetail.org.springframework.web.servlet.resource.NoResourceFoundException=Такого адреса в API нет
problemDetail.org.springframework.http.converter.HttpMessageNotReadableException=Тело запроса — некорректный JSON
problemDetail.org.springframework.beans.TypeMismatchException=Параметр «{0}» имеет неверный формат
Spring Boot читает messages.properties в UTF-8 и сам подключает его к обработчику

Как проверить: тесты MockMvc и ответы

@WebMvcTest поднимает только веб-слой — контроллеры, @ControllerAdvice, конвертеры JSON — без сервисов и базы. Сервис подменяем через @MockitoBean и заставляем бросать нужные исключения. OutputCaptureExtension из Spring Boot перехватывает вывод, чтобы проверить, что errorId из ответа попал в лог вместе со стектрейсом.

src/test/java/com/example/shop/error/GlobalExceptionHandlerTest.java
package com.example.shop.error;

import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.matchesPattern;
import static org.hamcrest.Matchers.not;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.example.shop.product.ProductController;
import com.example.shop.product.ProductService;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

// срез веб-слоя: контроллер + GlobalExceptionHandler, сервис подменён моком
@WebMvcTest(ProductController.class)
@ExtendWith(OutputCaptureExtension.class)
class GlobalExceptionHandlerTest {

    @Autowired
    MockMvc mvc;

    @MockitoBean
    ProductService service;

    @Test
    void fieldErrorsBecome400WithPointers() throws Exception {
        mvc.perform(post("/api/products").contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name": " ", "sku": "abc", "price": -5}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.type").value("https://api.example.com/problems/validation-error"))
                .andExpect(jsonPath("$.errors", hasSize(3)))
                .andExpect(jsonPath("$.errors[?(@.pointer == '#/name')].detail").value("Укажите название"))
                .andExpect(jsonPath("$.errors[?(@.pointer == '#/price')].detail").value("Цена должна быть больше нуля"));
    }

    @Test
    void parameterErrorsBecome400() throws Exception {
        mvc.perform(get("/api/products").param("limit", "500"))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.errors[0].parameter").value("limit"))
                .andExpect(jsonPath("$.errors[0].detail").value("Не больше 100"));
    }

    @Test
    void notFoundBecomes404() throws Exception {
        given(service.get(42)).willThrow(new NotFoundException("Товар 42 не найден"));

        mvc.perform(get("/api/products/42"))
                .andExpect(status().isNotFound())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.type").doesNotExist()) // нет поля — значит about:blank
                .andExpect(jsonPath("$.title").value("Not Found"))
                .andExpect(jsonPath("$.detail").value("Товар 42 не найден"))
                .andExpect(jsonPath("$.instance").value("/api/products/42"));
    }

    @Test
    void unknownUrlIsAlsoProblemJson() throws Exception {
        mvc.perform(get("/api/unknown"))
                .andExpect(status().isNotFound())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.detail").value("Такого адреса в API нет")); // из messages.properties
    }

    @Test
    void conflictBecomes409WithOwnType() throws Exception {
        given(service.create(any())).willThrow(
                new ConflictException("duplicate-sku", "Артикул уже занят", "Товар с артикулом CUP-01 уже есть"));

        mvc.perform(post("/api/products").contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name": "Кружка", "sku": "CUP-01", "price": 2500}
                                """))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.type").value("https://api.example.com/problems/duplicate-sku"))
                .andExpect(jsonPath("$.title").value("Артикул уже занят"));
    }

    @Test
    void brokenJsonDoesNotEchoParserDetails() throws Exception {
        mvc.perform(post("/api/products").contentType(MediaType.APPLICATION_JSON).content("{oops"))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.detail").value("Тело запроса — некорректный JSON"));
    }

    @Test
    void unexpectedErrorBecomes500WithoutLeak(CapturedOutput output) throws Exception {
        given(service.get(7)).willThrow(new IllegalStateException("connect to db-prod.internal:5432 failed"));

        String body = mvc.perform(get("/api/products/7"))
                .andExpect(status().isInternalServerError())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.errorId").value(matchesPattern("[0-9a-f-]{36}")))
                .andExpect(content().string(not(containsString("db-prod"))))
                .andExpect(content().string(not(containsString("IllegalStateException"))))
                .andReturn().getResponse().getContentAsString();

        // тот же errorId — в логе, рядом с исключением и стектрейсом
        String errorId = JsonPath.read(body, "$.errorId");
        assertThat(output).contains(errorId).contains("IllegalStateException: connect to db-prod.internal:5432 failed");
    }
}
terminal
$ docker run --rm -v "$PWD:/app" -v "$HOME/.m2:/root/.m2" -w /app maven:3.10-eclipse-temurin-25 mvn -B package
…
[INFO] Running com.example.shop.error.GlobalExceptionHandlerTest
…
… ERROR … c.e.shop.error.GlobalExceptionHandler    : Необработанная ошибка c3a6bdae-f8a6-4977-804a-6207569c20e5 в GET /api/products/7

java.lang.IllegalStateException: connect to db-prod.internal:5432 failed
    at com.example.shop.product.ProductService.get(ProductService.java:24) ~[classes/:na]
…
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 22.85 s -- in com.example.shop.error.GlobalExceptionHandlerTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.864 s -- in com.example.shop.ShopApplicationTests
…
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
…
[INFO] BUILD SUCCESS
фрагмент вывода: запись ERROR в логе — ожидаемый результат теста на 500, сборка проходит

Ответ на ошибку валидации из прогона тестов (отформатирован для чтения). Порядок элементов errors между запусками может меняться, поэтому тест ищет элемент по pointer, а не по индексу:

JSON
{
  "detail": "Проверьте параметры запроса",
  "instance": "/api/products",
  "status": 400,
  "title": "Ошибка валидации",
  "type": "https://api.example.com/problems/validation-error",
  "errors": [
    {
      "pointer": "#/name",
      "detail": "Укажите название"
    },
    {
      "pointer": "#/price",
      "detail": "Цена должна быть больше нуля"
    },
    {
      "pointer": "#/sku",
      "detail": "Артикул — от 3 до 20 символов: A–Z, 0–9 и дефис"
    }
  ]
}
POST /api/products с телом {"name": " ", "sku": "abc", "price": -5}
JSON
{
  "detail": "Внутренняя ошибка сервера. Сообщите в поддержку код 685e1ab2-2e61-4daf-a261-784ddfeec9b3",
  "instance": "/api/products/7",
  "status": 500,
  "title": "Internal Server Error",
  "errorId": "685e1ab2-2e61-4daf-a261-784ddfeec9b3"
}
непредвиденная ошибка: ни текста исключения, ни имени класса — только код для поддержки

На запущенном приложении (java -jar target/shop-0.0.1-SNAPSHOT.jar) настоящий сервис даёт тот же формат: повторное создание товара с тем же артикулом возвращает 409.

terminal
$ curl -s -X POST localhost:8080/api/products -H "Content-Type: application/json" -d '{"name": "Кружка", "sku": "CUP-01", "price": 2500}'
{"id":1,"name":"Кружка","sku":"CUP-01","price":2500}

$ curl -s -i -X POST localhost:8080/api/products -H "Content-Type: application/json" -d '{"name": "Кружка", "sku": "CUP-01", "price": 2500}'
HTTP/1.1 409 
Content-Type: application/problem+json
Transfer-Encoding: chunked
…

{"detail":"Товар с артикулом CUP-01 уже есть","instance":"/api/products","status":409,"title":"Артикул уже занят","type":"https://api.example.com/problems/duplicate-sku"}

Что не отдавать клиенту и ограничения

  • Стектрейс, имя класса и ex.getMessage() непредвиденных ошибок: раздел RFC 9457 о безопасности прямо предупреждает о раскрытии деталей реализации, а OWASP советует отвечать общим сообщением и записывать подробности в серверный лог
  • Текст SQL, имена таблиц, адреса внутренних сервисов и версии библиотек
  • Чужие персональные данные в detail — например, кому принадлежит уже занятый email
  • Технические тексты Spring по умолчанию: замените их в messages.properties
  • Ошибки, брошенные до DispatcherServlet — например, в фильтрах Spring Security, — до @RestControllerAdvice не доходят: механизм @ExceptionHandler работает на уровне DispatcherServlet. Такие ответы формирует стандартный /error Spring Boot, и для единого вида их настраивают отдельно.
  • Обработчик Exception.class перехватит и исключения, которые должны обрабатываться дальше по цепочке, например AccessDeniedException из @PreAuthorize: вместо 403 получится 500. Для таких типов добавьте отдельный @ExceptionHandler или бросьте исключение повторно — документация Spring описывает, что повторно брошенное исключение уходит дальше по цепочке обработчиков.
  • Проверка уникальности артикула в памяти — упрощение. С базой данных уникальность гарантирует уникальный индекс, а нарушение ограничения нужно так же превращать в 409, без текста ошибки базы.
  • errorId — минимальная связка ответа и лога. Если в проекте включена трассировка, Spring Boot пишет в лог идентификатор корреляции, и клиенту удобнее отдавать его.
  • Интерфейс должен показывать пользователю свои тексты, выбранные по type и коду ответа. RFC 9457 не советует разбирать detail программно, а title для about:blank Spring берёт из английской фразы кода — «Not Found».

Ссылки и следующий шаг

  • «Spring Boot: REST API с нуля» — проект с JPA, валидацией и тестами, в который этот обработчик встраивается без изменений.
  • «Проектирование API: ресурсы, версии, ошибки, пагинация» — какие типы проблем заводить и как описать формат ошибок в контракте OpenAPI.
  • «Обработка ошибок: try/catch, async-ошибки и понятные сообщения» — где ловить ошибки и чем сообщение пользователю отличается от записи в логе.
  • Официальные источники — ниже: раздел Spring Framework «Error Responses», RFC 9457 и памятка OWASP по обработке ошибок.
Как проверено5 · пройденосверено 12 октября 2026 г.
  • запущено, работает

    проект shop (NotFoundException, ConflictException, GlobalExceptionHandler, Product, ProductRequest, ProductService, ProductController, messages.properties, GlobalExceptionHandlerTest) — RU и EN-копия с английскими строками

    проект со start.spring.io (Boot 4.1.1, Java 25, web+validation); docker maven:3.10-eclipse-temurin-25 (Maven 3.10.0, Java 25.0.4.1): `mvn -B clean package` — 8/8 тестов (7 MockMvc в @WebMvcTest: 400 поля, 400 параметр, 404 домен, 404 неизвестный адрес, 409, 400 битый JSON, 500 без утечки + errorId в логе через OutputCaptureExtension; + contextLoads), BUILD SUCCESS, repackage; EN-копия (скрипт перевода строк) — 8/8. Версии из dependency:list: Spring Boot 4.1.1, spring-webmvc 7.0.9, Hibernate Validator 9.1.3.Final, Jackson 3.1.5, Tomcat 11.0.24, JUnit 6.0.3, Mockito 5.23.0

  • запущено, работает

    JSON-ответы в статье (400 валидация, 500) и вывод mvn package

    тела сняты временным тестом DumpBodiesTest (MockMvc, UTF-8) в RU и EN-проектах и вставлены генератором (JSON отформатирован); дополнительно сняты 400 параметр, 404, 404 адрес, 409, 400 битый JSON, 400 неверный id, 405; фрагмент mvn — из реального лога прогона; временный тест удалён перед финальной сборкой

  • запущено, работает

    curl-сессия: POST 201 и повторный POST → 409 на запущенном jar

    jar в eclipse-temurin:25-jre (контейнер kb-r06-app), curl из curlimages/curl:8.17.0 в сетевом пространстве приложения; RU и EN; строка Date заменена на «…»

  • запущено, работает

    автоконфигурация Spring Boot не создаёт свой обработчик при наличии нашего

    javap -v WebMvcAutoConfiguration$ProblemDetailsErrorHandlingConfiguration из spring-boot-webmvc-4.1.1.jar: @ConditionalOnBooleanProperty(spring.mvc.problemdetails.enabled) + бин с @ConditionalOnMissingBean(ResponseEntityExceptionHandler)

  • запущено, работает

    решение упражнения (messages.properties для HttpRequestMethodNotSupportedException + тест 405) — RU и EN

    копии shop-ex / shop-ex-en: `mvn -B test` — 10/10 (2 теста ExerciseTest: 405 с Allow и своим detail, 400 на /api/products/abc + 8 основных)

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.