Опирается на правила: R-ERR-RETRY-1R-ERR-RETRY-3 и R-ERR-RETRY-X1 из Error Handling Style Guide → раздел 5. Retry / no-retry семантика.

Важно знать

  • DomainExceptionникогда не retry. Бизнес-правило детерминированно: те же данные → тот же fail.
  • ValidationExceptionникогда не retry. Тот же input → тот же fail.
  • IntegrationExceptionretry-safe при идемпотентности. Write без Idempotency-Key — retry запрещён (R-RES-RE-X1).
  • TechnicalException — обычно retry после latency.
  • HTTP 4xx от внешней системы — не retry. «Мы послали некорректное», повтор не поможет.
  • HTTP 5xx и timeout — retry safe только при идемпотентности. Без Idempotency-Key на write — money может списаться дважды.
  • @Retry на @ExceptionHandler-методе — бесполезен. Edge-handler уже вне retry-цикла.

Retry — это дешёвый способ пережить транзиентный сбой внешней системы. Но та же механика может стать оружием против целостности: повторно списать деньги, отправить SMS, создать платёж. Правило в одну фразу: retry безопасен только когда операция идемпотентна. И тип исключения — первый признак, можно ли retry'ить вообще. Раскрытие правил R-ERR-RETRY-* ниже.

Таблица по типам

R-ERR-RETRY-1: однозначный ответ на «retry или нет» по типу.

ТипRetryПричина
DomainException❌ НикогдаБизнес-правило детерминированно. Тот же state + тот же запрос → тот же fail.
ValidationException❌ НикогдаНевалидный input. Те же данные → тот же fail.
IntegrationException✅ При идемпотентностиСетевой сбой / 5xx — обычно транзиентно. Но только если operation идемпотентна.
TechnicalException✅ После latencyВнутренняя проблема (БД-таймаут, OOM). Часто латерально проходит сама.

«Retry» здесь — это автоматический retry внутри сервиса (через @Retry Resilience4j или ручной цикл). Клиентский retry (пользователь нажимает кнопку ещё раз) — отдельная история, его контролирует клиент.

DomainException и ValidationException — никогда

@Retry(name = "sber")
public Order handle(CreateOrderCommand cmd) {
    var order = Order.create(cmd);                                    // ← может бросить DomainException
    // ...
}

Если Order.create(...) бросает OrderTooLargeException (правило «не больше 1000$»), retry не поможет:

  • Состояние агрегата не изменится между попытками — заказ всё ещё «слишком большой».
  • Правило детерминированно — оно сработает на каждой попытке одинаково.
  • Получаем 3 одинаковых лога подряд с OrderTooLargeException, и спустя 3 секунды ту же 422-ошибку.

Конфигурация Resilience4j Retry должна явно исключать DomainException и ValidationException:

resilience4j:
  retry:
    instances:
      sber:
        max-attempts: 3
        wait-duration: 1s
        ignore-exceptions:
          - ru.example.core.domain.DomainException
          - ru.example.core.adapter.in.ValidationException
        retry-exceptions:
          - ru.example.core.domain.IntegrationException

IntegrationException — retry-safe при идемпотентности

R-ERR-RETRY-3: HTTP 5xx и timeout — это транзиентные сбои, обычно проходят. Но повторять можно только если операция идемпотентна.

Идемпотентность = «повторный вызов с теми же параметрами дают тот же результат, что и одиночный». Для money-операций реализуется через Idempotency-Key — клиент посылает уникальный ключ, сервер при повторе с тем же ключом возвращает результат первой попытки, не выполняя операцию заново.

Read-операции (getOrderById, listOrders) — естественно идемпотентны. Retry safe.

Write-операции (createOrder, chargePayment) — идемпотентны только если есть Idempotency-Key. Без него retry небезопасен:

// ПЛОХО — retry без идемпотентности
@Retry(name = "sber")
public PaymentResult charge(ChargeCommand cmd) {
    return sberApi.charge(cmd);                                       // ← каждая попытка может списать
}

Если первая попытка успешно списала 100$, но мы не получили ответ из-за timeout (сеть зависла, ответ потерян) — Resilience4j повторит запрос. Sber снова спишет 100$. Пользователю снимут 200$, мы будем разбираться неделю.

Правильно — передавать Idempotency-Key с запросом, и retry-able только через эту защиту:

// ХОРОШО
@Retry(name = "sber")
public PaymentResult charge(ChargeCommand cmd) {
    return sberApi.charge(cmd, cmd.idempotencyKey());                 // ← Sber поймёт повтор по ключу
}

См. AUTH-19 в Auth Patterns Style Guide — про Idempotency-Key для money-операций. И R-RES-RE-X1 в Resilience Style Guide — про запрет retry без идемпотентности на write.

HTTP 4xx — не retry

R-ERR-RETRY-2: 4xx означает «мы послали что-то некорректное». Повтор не поможет — те же данные дадут тот же 4xx.

// В out-adapter
try {
    return sberApi.register(req);
} catch (HttpClientErrorException.BadRequest ex) {
    // 4xx — не retry. Мапим в domain-error.
    throw new InvalidPaymentRequestException(req.getOrderId(), ex.getResponseBodyAsString());
}

InvalidPaymentRequestException — это DomainException-наследник (не IntegrationException). Бросается с domain-meaning «наш запрос некорректен с точки зрения внешней системы». Edge превращает в 422 для нашего клиента.

Retry-конфиг должен не retry'ить это:

resilience4j:
  retry:
    instances:
      sber:
        ignore-exceptions:
          - ru.example.core.domain.DomainException             # ← включая InvalidPaymentRequestException

HTTP 5xx и timeout — retry safe только при идемпотентности

R-ERR-RETRY-3 (продолжение): эти ошибки транзиентные, повтор имеет смысл — но только если безопасен.

// В out-adapter
try {
    return sberApi.register(req);
} catch (HttpServerErrorException ex) {                        // 5xx
    throw new SberRegisterException("Sber 5xx", ex);            // ← IntegrationException
} catch (ResourceAccessException ex) {                          // timeout, connection refused
    throw new SberRegisterException("Sber timeout/connect", ex);
}

SberRegisterException — это IntegrationException-наследник. Resilience4j Retry, настроенный на retry IntegrationException, повторит.

Но! Если register — это write-операция, не идемпотентная, retry безопасен только при наличии Idempotency-Key. Без него — R-RES-RE-X1: retry запрещён в конфиге, fallback бросает SberUnavailableException, клиент сам решит, повторять ли.

Что запрещено

R-ERR-RETRY-X1: @Retry на @ExceptionHandler-методе.

// ПЛОХО — бессмысленно
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IntegrationException.class)
    @Retry(name = "sber")                                              // ← не имеет смысла
    public ResponseEntity<...> handleIntegration(IntegrationException ex) {
        // ...
    }
}

Что не так:

  • Edge-handler уже вне retry-цикла. Исключение долетело сюда — значит, все retry в out-adapter и резильянс-обёртках уже отработали.
  • Retry на «как мы отвечаем клиенту HTTP-ошибкой» — бессмыслица. HTTP-ответ не retry'ится.

Retry — в out-adapter, на уровне port-вызовов. Не в edge.

Куда дальше