Опирается на правила:
R-ERR-MAP-1…R-ERR-MAP-5иR-ERR-MAP-X1…R-ERR-MAP-X3из Error Handling Style Guide → раздел 3. Mapping в ProblemDetails.
Важно знать
DomainException→ 409 (нарушение текущего состояния) или 422 (нарушение бизнес-инвариантов).ValidationException→ 400.errors-массив с per-field подробностями.IntegrationException→ 502 (внешка вернула 5xx), 503 (CB открыт или Bulkhead отверг), 504 (timeout).TechnicalException→ 500. Минимум информации в response — только «Internal Server Error» +traceId.- Catch-all (
Throwable) → 500. Лог ERROR с полным stacktrace — сигнал бага, создавать issue.typeв ProblemDetails — URL на доменный код ошибки в каталоге (https://api.example.com/errors/insufficient-funds).properties— структурированный контекст (customerId,requested,available,traceId).- Запрещено: HTTP 200 при ошибке с
{success: false}; stacktrace вdetail; сообщения с PII или схемой БД вdetail.
ProblemDetails — это стандарт ответа об ошибке, формализованный в RFC 9457. Spring Boot 3 поддерживает его из коробки через ProblemDetail. Цель — единый машинно-читаемый формат: клиент видит status, type, detail, properties — может реагировать программно (не парсить error-сообщение строкой). Раскрытие правил R-ERR-MAP-* ниже.
DomainException → 409 / 422
R-ERR-MAP-1: бизнес-правила нарушены — Conflict или Unprocessable Entity.
409 Conflict — когда операция конфликтует с текущим состоянием ресурса:
- «Нельзя отменить отгруженный заказ» (
OrderAlreadyShippedException). - «Email уже используется» (
EmailAlreadyTakenException). - «Заказ уже подтверждён, второй раз нельзя» (
OrderAlreadyConfirmedException).
422 Unprocessable Entity — когда бизнес-инвариант нарушен:
- «Сумма заказа меньше минимальной» (
OrderTooLargeException). - «Недостаточно средств» (
InsufficientFundsException). - «Клиент не подходит под условия акции» (
CustomerNotEligibleException).
Граница тонкая; на практике 422 — чаще. 409 — когда речь о версии ресурса (включая optimistic locking).
@ExceptionHandler(InsufficientFundsException.class)
ProblemDetail handleInsufficientFunds(InsufficientFundsException ex) {
var pd = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY,
"Operation cannot be completed due to insufficient funds"
);
pd.setType(URI.create("https://api.example.com/errors/insufficient-funds"));
pd.setProperty("customerId", ex.getCustomerId().value());
pd.setProperty("requested", ex.getRequested());
pd.setProperty("available", ex.getAvailable());
pd.setProperty("traceId", MDC.get("traceId"));
return pd;
}
Поля:
status— HTTP-статус, в нашем случае 422.type— стабильный URL на каталог ошибок (docs/spec/errors/insufficient-funds.md). Клиент по этому URL может прочитать «что это, как реагировать».title— короткое имя ошибки (автоматически берётся из HTTP-статуса).detail— человеческое описание для UI.properties— структурированный контекст. Клиент достанетcustomerId,requested,availableи покажет в UI.
ValidationException → 400
R-ERR-MAP-2: невалидный input. В errors-массиве per-field подробности.
Spring + Jakarta Validation генерируют MethodArgumentNotValidException для @Valid-аннотированных тел запросов. Обрабатываем в @RestControllerAdvice:
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
var pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
pd.setType(URI.create("https://api.example.com/errors/validation"));
List<Map<String, Object>> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> Map.<String, Object>of(
"field", fe.getField(),
"code", fe.getCode(),
"message", fe.getDefaultMessage(),
"rejectedValue", fe.getRejectedValue()
))
.toList();
pd.setProperty("errors", errors);
pd.setProperty("traceId", MDC.get("traceId"));
return pd;
}
Пример ответа:
{
"status": 400,
"type": "https://api.example.com/errors/validation",
"detail": "Validation failed",
"errors": [
{"field": "customerId", "code": "NotNull", "message": "must not be null", "rejectedValue": null},
{"field": "amount", "code": "Min", "message": "must be greater than 0", "rejectedValue": -10}
],
"traceId": "abc123"
}
Подробности про валидацию — в Validation Style Guide.
IntegrationException → 502 / 503 / 504
R-ERR-MAP-3: разная семантика по типу проблемы.
502 Bad Gateway — внешка вернула 5xx с понятным телом. «Мост сломан», мы как сервис в порядке, но не можем выполнить запрос.
@ExceptionHandler(SberRegisterException.class)
ProblemDetail handleSberRegister(SberRegisterException ex) {
return buildIntegrationProblem(HttpStatus.BAD_GATEWAY,
"Payment provider temporarily unavailable", ex);
}
503 Service Unavailable — наш CircuitBreaker открыт (CallNotPermittedException через R-RES-FB-*) или Bulkhead отверг (RejectedExecutionException). Сервис не готов обслужить прямо сейчас.
@ExceptionHandler(SberUnavailableException.class)
ProblemDetail handleSberUnavailable(SberUnavailableException ex) {
return buildIntegrationProblem(HttpStatus.SERVICE_UNAVAILABLE,
"Payment provider temporarily unavailable, try again later", ex);
}
504 Gateway Timeout — таймаут (наш или внешний).
@ExceptionHandler(SberTimeoutException.class)
ProblemDetail handleSberTimeout(SberTimeoutException ex) {
return buildIntegrationProblem(HttpStatus.GATEWAY_TIMEOUT,
"Payment provider did not respond in time", ex);
}
Важно: в detail не вкладываем сырое тело внешней системы.
// ПЛОХО
pd.setDetail("Sber returned: " + ex.getSberResponseBody()); // ← может содержать PII / debug
Тело внешней системы может содержать PII (email пользователя, внутренние коды), debug-информацию о структуре их API, что-то ещё. Это утечка. Общая фраза + traceId для cross-system корреляции — этого достаточно.
TechnicalException → 500
R-ERR-MAP-4: минимум информации.
@ExceptionHandler(TechnicalException.class)
ProblemDetail handleTechnical(TechnicalException ex) {
log.error("Technical error", ex); // полный stacktrace в логи
var pd = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"Internal server error" // ← общая фраза, без деталей
);
pd.setProperty("traceId", MDC.get("traceId"));
return pd;
}
В response только:
status: 500detail: "Internal server error"traceId— чтобы оператор мог найти в логах.
Никакого ex.getMessage() в detail — там может оказаться что угодно («Connection refused at jdbc:postgresql://internal-db:5432/...»). По правилу AUTH-18 из Auth Patterns Style Guide — PII и debug-информация не уходят в response.
Catch-all → 500
R-ERR-MAP-5: @ExceptionHandler(Throwable.class) — последний бастион.
@ExceptionHandler(Throwable.class)
ProblemDetail handleUnexpected(Throwable ex) {
log.error("Unexpected error — not in hierarchy", ex); // ← ERROR + сигнал бага
var pd = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"Internal server error"
);
pd.setProperty("traceId", MDC.get("traceId"));
return pd;
}
Если сюда попало — это сигнал бага: появилось исключение, которое не описано в иерархии. Алёрт на рост unexpected-counter → создавать issue в трекере → добавить тип в иерархию.
См. Observability ошибок — про метрики и алёрты.
Что запрещено
R-ERR-MAP-X1: HTTP 200 при ошибке с {"success": false, "error": "..."} в body.
// ПЛОХО
HTTP 200 OK
{
"success": false,
"error": "Insufficient funds"
}
Почему катастрофа:
- Мониторинг прозевает. Sentry, Grafana-дашборды считают «всё что не 4xx/5xx — успех». Алёрты не сработают.
- HTTP-семантика проигнорирована. REST — это не «truth in JSON», это «truth in HTTP-status + JSON». Клиенту приходится парсить тело, чтобы узнать «успех или нет».
- Конвенция SOAP-эпохи. В 2026 году так не делают.
Используем HTTP-коды по назначению. 4xx — клиент виноват, 5xx — сервер виноват, 2xx — успех.
R-ERR-MAP-X2: stacktrace в detail-поле ProblemDetail.
// ПЛОХО
pd.setDetail(getStackTraceAsString(ex)); // ← утечка
Утечка:
- Имена внутренних классов (понятно, что у нас Spring/jOOQ — атакующий может искать known vulnerabilities).
- Версии библиотек (которые могут иметь CVE).
- Пути на сервере (
/app/order-service/...) — раскрытие структуры deployment.
Stacktrace — только в логи.
R-ERR-MAP-X3: ex.getMessage() как detail без санитизации.
// ПЛОХО
pd.setDetail(ex.getMessage()); // ← может быть что угодно
Если ex — это SQLException, его getMessage() будет: "ERROR: relation \"order_doc\" does not exist". Это раскрытие схемы БД клиенту.
Используем наши доменные исключения, в которых конструктор фиксирует контекст (см. Иерархия исключений → R-ERR-HIER-5). InsufficientFundsException.getMessage() — «Insufficient funds: customer=cust-42, requested=$100, available=$50» — безопасно для отдачи клиенту.
Куда дальше
- Error Handling Style Guide → раздел 3. Mapping в ProblemDetails — нормативные формулировки.
- REST API Style Guide → R-API-ERR-* — общий формат ProblemDetails в REST.
- Иерархия исключений — типы, которые мы мапим.
- Где throw, где catch — где живёт
GlobalExceptionHandler. - Auth Patterns → AUTH-18 — про PII в response.