Опирается на правила: R-ERR-MAP-1R-ERR-MAP-5 и R-ERR-MAP-X1R-ERR-MAP-X3 из Error Handling Style Guide → раздел 3. Mapping в ProblemDetails.

Важно знать

  • DomainException409 (нарушение текущего состояния) или 422 (нарушение бизнес-инвариантов).
  • ValidationException400. errors-массив с per-field подробностями.
  • IntegrationException502 (внешка вернула 5xx), 503 (CB открыт или Bulkhead отверг), 504 (timeout).
  • TechnicalException500. Минимум информации в 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: 500
  • detail: "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» — безопасно для отдачи клиенту.

Куда дальше