Единый формат ошибок в Spring Boot делается одним классом: @RestControllerAdvice, унаследованный от ResponseEntityExceptionHandler, возвращает ProblemDetail по стандарту RFC 9457 для всех случаев — ошибок валидации, 404, 409 и непредвиденных 500. Клиент всегда получает application/problem+json с понятным detail, а стектрейс остаётся в логе и связывается с ответом через errorId.
После материала вы сможете
- подключить
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 г.
Содержание
Задача: ошибки 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: бизнес-логика не зависит от веб-слоя, а код ответа выбирается в одном месте.
package com.example.shop.error;
// ресурса нет: обработчик превратит исключение в 404
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}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 найдёт класс сканированием компонентов и применит ко всем контроллерам.
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. - Поле
instanceSpring заполняет путём запроса сам, аtypeсо значениемabout:blankв JSON не выводит: по RFC 9457 отсутствие поля и означаетabout:blank. errorIdиerrors— расширения формата. RFC разрешает добавлять свои поля, а клиенты обязаны игнорировать незнакомые.
Как подключить
Зависимости
Нужны
spring-boot-starter-webmvcиspring-boot-starter-validation, для тестов —spring-boot-starter-webmvc-test. Проект со start.spring.io с зависимостямиwebиvalidationуже содержит их.Скопируйте три класса
NotFoundException,ConflictExceptionиGlobalExceptionHandler— в пакетerror. Заменитеhttps://api.example.com/problems/на адрес, где будет документация ваших типов ошибок: RFC 9457 рекомендует, чтобы URI типа вёл на понятное человеку описание.Бросайте исключения из сервиса
Вместо
return nullилиResponseEntity.notFound()в контроллере сервис бросаетNotFoundExceptionилиConflictException. Контроллер остаётся безtry/catch.Перепишите тексты встроенных ошибок
По умолчанию на неизвестный адрес Spring отвечает
detail«No static resource api/unknown.» — лишняя подсказка о внутреннем устройстве. Тексты задаются вmessages.propertiesпо ключуproblemDetail.плюс полное имя класса исключения.Свойство problemdetails не нужно
spring.mvc.problemdetails.enabled=trueвключает встроенный обработчик Spring Boot, только если своего бинаResponseEntityExceptionHandlerнет. С классом из рецепта свойство не требуется.
<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>Сервис и контроллер, на которых проверялся рецепт: хранилище в памяти, уникальный артикул и ограничение limit прямо на параметре метода.
package com.example.shop.product;
import java.math.BigDecimal;
public record Product(long id, String name, String sku, BigDecimal price) {
}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) {
}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;
}
}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);
}
}# тексты для встроенных ошибок Spring MVC: не раскрываем внутреннее устройство
problemDetail.org.springframework.web.servlet.resource.NoResourceFoundException=Такого адреса в API нет
problemDetail.org.springframework.http.converter.HttpMessageNotReadableException=Тело запроса — некорректный JSON
problemDetail.org.springframework.beans.TypeMismatchException=Параметр «{0}» имеет неверный форматmessages.properties в UTF-8 и сам подключает его к обработчикуКак проверить: тесты MockMvc и ответы
@WebMvcTest поднимает только веб-слой — контроллеры, @ControllerAdvice, конвертеры JSON — без сервисов и базы. Сервис подменяем через @MockitoBean и заставляем бросать нужные исключения. OutputCaptureExtension из Spring Boot перехватывает вывод, чтобы проверить, что errorId из ответа попал в лог вместе со стектрейсом.
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");
}
}$ 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Ответ на ошибку валидации из прогона тестов (отформатирован для чтения). Порядок элементов errors между запусками может меняться, поэтому тест ищет элемент по pointer, а не по индексу:
{
"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 и дефис"
}
]
}{
"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.
$ 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. Такие ответы формирует стандартный/errorSpring Boot, и для единого вида их настраивают отдельно. - Обработчик
Exception.classперехватит и исключения, которые должны обрабатываться дальше по цепочке, напримерAccessDeniedExceptionиз@PreAuthorize: вместо 403 получится 500. Для таких типов добавьте отдельный@ExceptionHandlerили бросьте исключение повторно — документация Spring описывает, что повторно брошенное исключение уходит дальше по цепочке обработчиков. - Проверка уникальности артикула в памяти — упрощение. С базой данных уникальность гарантирует уникальный индекс, а нарушение ограничения нужно так же превращать в 409, без текста ошибки базы.
errorId— минимальная связка ответа и лога. Если в проекте включена трассировка, Spring Boot пишет в лог идентификатор корреляции, и клиенту удобнее отдавать его.- Интерфейс должен показывать пользователю свои тексты, выбранные по
typeи коду ответа. RFC 9457 не советует разбиратьdetailпрограммно, аtitleдляabout:blankSpring берёт из английской фразы кода — «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.