Опирается на правила: R-RES-RE-1R-RES-RE-5 и R-RES-RE-X1R-RES-RE-X4 из Resilience Style Guide → раздел 5. Retry.

Важно знать

  • @Retry допустим только при идемпотентности: либо метод — read (GET), либо команда c Idempotency-Key (внешняя система дедуплицирует).
  • max-attempts: 3 — типовое (включая первую попытку). 5 — верхний предел. Больше — это task-queue.
  • enable-exponential-backoff: true обязателен. Линейный retry удваивает нагрузку на и без того лежачую систему.
  • retry-exceptionsIOException, HttpServerErrorException (5xx). ignore-exceptionsHttpClientErrorException (4xx). 4xx — контрактные ошибки, повтор не поможет.
  • In-memory Retry для транзиентов <5s. Task-queue для отказов >30s.
  • @Retry на write без Idempotency-Key — главный source двойных платежей. На 5xx ответ может быть «не дошло» или «дошло, но ответ потерян».
  • @Retryable Spring 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-queueSync-блок 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 создаст второй платёж
}

Сценарий:

  1. Наш сервис отправил register(amount=1000).
  2. Sber записал транзакцию, начал формировать ответ.
  3. По пути ответ потерян (network blip, его TCP-сокет закрылся).
  4. Наш сервис получает IOException, R4J ретраит.
  5. Sber видит второй запрос с тем же payload без Idempotency-Key — создаёт второй платёж.
  6. У клиента списано 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-KeyR-RES-RE-X1Либо ключ, либо без retry
@Retry на 4xxR-RES-RE-X2ignore-exceptions: HttpClientErrorException
@Retry без exponential backoffR-RES-RE-X3enable-exponential-backoff: true
@Retryable (Spring Retry) для outboundR-RES-RE-X4Resilience4j @Retry
max-attempts > 5 для in-memory retryR-RES-RE-3Task-queue с DB
In-memory retry для отказов >30sR-RES-RE-4Task-queue (durable, переживает рестарт)
Retry без интеграции с CBR-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-операций.