Опирается на правила:
R-RES-RE-1…R-RES-RE-5иR-RES-RE-X1…R-RES-RE-X4из Resilience Style Guide → раздел 5. Retry.
Важно знать
@Retryдопустим только при идемпотентности: либо метод — read (GET), либо команда cIdempotency-Key(внешняя система дедуплицирует).max-attempts: 3— типовое (включая первую попытку). 5 — верхний предел. Больше — это task-queue.enable-exponential-backoff: trueобязателен. Линейный retry удваивает нагрузку на и без того лежачую систему.retry-exceptions—IOException,HttpServerErrorException(5xx).ignore-exceptions—HttpClientErrorException(4xx). 4xx — контрактные ошибки, повтор не поможет.- In-memory Retry для транзиентов <5s. Task-queue для отказов >30s.
@Retryна write безIdempotency-Key— главный source двойных платежей. На 5xx ответ может быть «не дошло» или «дошло, но ответ потерян».@RetryableSpring Retry — legacy. Не интегрирован с CB / Bulkhead. Использовать Resilience4j.
Retry — самый опасный из resilience-инструментов. Большинство retry-багов прода — это «дважды списали деньги», «послали SMS трижды», «создали два заказа». Поэтому правило простое: retry только тогда, когда операция идемпотентна по дизайну. Любое сомнение — нет retry. Раскрытие раздела 5 гайда.
Когда retry допустим
R-RES-RE-1: ровно два случая.
Случай 1: read-операция (GET-эквивалент)
Чтение по определению идемпотентно: 1 запрос или 100 запросов — результат тот же, побочных эффектов нет.
@CircuitBreaker(name = "sber")
@Retry(name = "sber")
@Override
public OrderStatus getOrderStatus(String orderId) {
return executeCall(sberApi.getOrderStatus(orderId));
}
Здесь retry безопасен. Если первый вызов вернул transient 503, второй попробует ещё раз — максимум потратит 500ms лишних.
Случай 2: write с Idempotency-Key
Если внешняя система обязалась дедуплицировать по ключу, retry безопасен.
@CircuitBreaker(name = "sber")
@Retry(name = "sber")
public RegisterResult register(RegisterCommand cmd) {
SberRegisterRequest req = mapper.toApiRequest(cmd)
.idempotencyKey(cmd.idempotencyKey()); // ← обязательно
return executeCall(sberApi.register(req, null));
}
Что критично:
Idempotency-Key— детерминированный (например, UUID v5 изorderId + operationили client-generated UUID v7 один раз на user-action). См. AUTH-19.- Внешняя система обязана гарантировать дедуп. Если эта гарантия не прописана в её контракте — retry на write запрещён, даже с ключом.
- TTL ключа на стороне внешней системы должен быть больше нашего max retry-window (включая task-queue).
Подробно про идемпотентность — в auth-patterns AUTH-19.
Конфиг retry
R-RES-RE-2: типовой блок в application.yml.
resilience4j.retry.instances.sber:
max-attempts: 3
wait-duration: 500ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2.0
retry-exceptions:
- java.io.IOException
- org.springframework.web.client.HttpServerErrorException
ignore-exceptions:
- org.springframework.web.client.HttpClientErrorException
Что считаем:
wait-duration: 500ms— первая пауза.multiplier: 2.0— следующая 1000ms, потом 2000ms.max-attempts: 3— итого попыток 3, in-memory время = 500 + 1000 = 1500ms между attempts.
Total пауза = 1.5s + 3 × callTimeout (если каждая попытка идёт до timeout). Это вписывается в SLA upstream-вызова (<5s).
max-attempts — 3 типовое, 5 предел
R-RES-RE-3: больше 5 попыток — это уже не in-memory retry, это task-queue.
Рассуждение:
- 3 attempts покрывают transient hiccup'ы: connection reset, instance restart внешней системы (она в k8s, под перезапуском 1-2 секунды).
- 5 attempts — для нестабильных систем с высокой baseline-ошибкой.
- 10+ attempts — нет, такая ситуация говорит о реальной деградации внешней системы; нужно либо открывать CB и идти в fallback, либо ставить задачу в task-queue.
Граница in-memory Retry vs task-queue
R-RES-RE-4: критический порог — около 5 секунд суммарного in-memory времени.
| Длительность отказа | Инструмент | Почему |
|---|---|---|
<5s (transient) | In-memory @Retry | Резон in-memory: быстро, бесплатно, не нагружает БД |
5–30s (обсуждать) | Обычно task-queue | Sync-блок handler-а на 30s = timeout upstream |
>30s (durable failure) | Task-queue с DB-driven scheduler | Переживает рестарт сервиса, не блокирует worker |
In-memory retry на 30s = thread-pool в зомби: 200 запросов сидят в @Retry блоке по 30s. Новые запросы получают BulkheadFullException или отказ Bulkhead. Сервис лежит из-за retry, а не из-за внешней системы.
Task-queue retry
R-RES-RE-5: durable retry — через таблицу БД с polling-scheduler.
CREATE TABLE order_confirmation_task (
task_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL,
status TEXT NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@Scheduled(fixedDelay = 5_000)
public void runDue() {
List<Task> due = repo.findDueForRetry(50); // FOR UPDATE SKIP LOCKED
for (Task t : due) {
try {
adapter.confirm(t);
repo.markCompleted(t.taskId());
} catch (Exception e) {
int next = t.retryCount() + 1;
if (next >= 10) {
repo.markFailed(t.taskId(), e.getMessage());
alertOps(t); // ← человек должен увидеть
} else {
Duration backoff = Duration.ofSeconds((long) Math.pow(2, next));
repo.scheduleRetry(t.taskId(), next, Instant.now().plus(backoff), e.getMessage());
}
}
}
}
Подробно — в Async и polling и PG Runtime → task-queue.
Что запрещено
@Retry на write без Idempotency-Key
R-RES-RE-X1: главный источник production-инцидентов.
// ПЛОХО — write retry без идемпотентности
@Retry(name = "sber")
public RegisterResult register(RegisterCommand cmd) {
return executeCall(sberApi.register(toApiRequest(cmd), null));
// ← если Sber вернул 503 после фактической записи, retry создаст второй платёж
}
Сценарий:
- Наш сервис отправил
register(amount=1000). - Sber записал транзакцию, начал формировать ответ.
- По пути ответ потерян (network blip, его TCP-сокет закрылся).
- Наш сервис получает
IOException, R4J ретраит. - Sber видит второй запрос с тем же payload без Idempotency-Key — создаёт второй платёж.
- У клиента списано 2000 вместо 1000.
Корректно: либо Idempotency-Key, либо вообще без @Retry.
@Retry на 4xx
R-RES-RE-X2: 4xx — это контрактные ошибки клиента (валидация, auth, not-found). Повтор не поможет — это наша ошибка.
resilience4j.retry.instances.sber:
ignore-exceptions:
- org.springframework.web.client.HttpClientErrorException # 4xx
Без ignore-exceptions на 4xx Retry повторит запрос несколько раз с тем же payload, получит тот же 4xx, потратит время. Логи мусорятся.
@Retry без exponential backoff
R-RES-RE-X3: линейный retry (3 × wait-duration: 500ms) — нагружает лежачую систему гуще.
# ПЛОХО — линейный retry
resilience4j.retry.instances.sber:
max-attempts: 3
wait-duration: 500ms
enable-exponential-backoff: false
3 запроса в 1.5 секунды — это load spike: ровно когда система оправляется, на неё снова валится трафик. Экспоненциальный backoff даёт «дыхание»: 500ms, 1s, 2s — система успевает между попытками.
@Retryable Spring Retry для outbound
R-RES-RE-X4: spring-retry пакет (@Retryable, @Recover) — legacy. Использовать Resilience4j.
// ПЛОХО — Spring Retry
@Retryable(value = IOException.class, maxAttempts = 3)
public RegisterResult register(RegisterCommand cmd) { ... }
@Recover
public RegisterResult recover(IOException e, RegisterCommand cmd) { ... }
Что не так:
- Не интегрирован с
@CircuitBreakerи@Bulkhead. CB не учитывает retry в sliding-window. - Metrics в Micrometer — нужно подкручивать вручную (
resilience4j-micrometerвсё уже даёт). - Конфиг в коде (
maxAttempts), не вapplication.yml— нельзя поменять через Spring Cloud Config.
Корректно: @Retry(name = "sber") из resilience4j-spring-boot3.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
@Retry на write без Idempotency-Key | R-RES-RE-X1 | Либо ключ, либо без retry |
@Retry на 4xx | R-RES-RE-X2 | ignore-exceptions: HttpClientErrorException |
@Retry без exponential backoff | R-RES-RE-X3 | enable-exponential-backoff: true |
@Retryable (Spring Retry) для outbound | R-RES-RE-X4 | Resilience4j @Retry |
max-attempts > 5 для in-memory retry | R-RES-RE-3 | Task-queue с DB |
| In-memory retry для отказов >30s | R-RES-RE-4 | Task-queue (durable, переживает рестарт) |
| Retry без интеграции с CB | R-RES-CB-1 | @CircuitBreaker + @Retry на том же методе |
Куда дальше
- Resilience → раздел 5. Retry — нормативные
R-RES-RE-*. - Async и polling — task-queue для долгих отказов.
- Circuit Breaker — fast-fail когда retry не помогает.
- Fallback — что делать когда retry исчерпан.
- Конфигурация — declarative config.
- Auth Patterns → AUTH-19 — Idempotency-Key для money-операций.