Опирается на правила:
R-ERR-RETRY-1…R-ERR-RETRY-3иR-ERR-RETRY-X1из Error Handling Style Guide → раздел 5. Retry / no-retry семантика.
Важно знать
DomainException— никогда не retry. Бизнес-правило детерминированно: те же данные → тот же fail.ValidationException— никогда не retry. Тот же input → тот же fail.IntegrationException— retry-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.
Куда дальше
- Error Handling Style Guide → раздел 5. Retry-семантика — нормативные формулировки.
- Resilience Style Guide → R-RES-RE-* — про
@Retry, exponential backoff, конфигурацию. - Auth Patterns → AUTH-19 — про
Idempotency-Keyдля money-операций. - Иерархия исключений — как 4xx маппится в
DomainException, 5xx — вIntegrationException.