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

Важно знать

  • retry.Do допустим только при идемпотентности: либо метод — read (GetOrderStatus, FindProduct), либо write с Idempotency-Key (внешняя система дедуплицирует).
  • retry.Attempts(3) — типовое (включая первую попытку). 5 — верхний предел. Больше — task-queue через таблицу *_task.
  • retry.BackOffDelay обязателен. Линейный retry.FixedDelay бьёт пачкой по уже деградирующей системе.
  • retry.RetryIf фильтрует только транзиентные ошибки: net.Error.Timeout() и 5xx. 4xx — контрактная ошибка клиента, повтор не поможет.
  • gobreaker.ErrOpenState и gobreaker.ErrTooManyRequestsне ретраить: CB уже решил, что система недоступна; retry только усугубит double-count в failure rate.
  • In-memory retry для транзиентов <5s. Task-queue для отказов >30s и durable-сценариев (переживание рестарта).
  • retry.Do на write без Idempotency-Key — главный источник двойных платежей. На 5xx ответ может быть «не дошло» или «дошло, ответ потерян».

Retry — самый опасный инструмент из набора resilience. Большинство production-инцидентов с дублями («списали дважды», «отправили SMS трижды», «создали два заказа») объединяет одно: где-то добавили retry.Do на write-метод, не убедившись в идемпотентности. Поэтому правило простое: retry только тогда, когда операция идемпотентна по дизайну. Любое сомнение — без retry.

Когда retry допустим

R-RES-RE-1: ровно два случая.

Случай 1: read-операция (GET-эквивалент)

Чтение идемпотентно по природе: 1 запрос или 100 запросов — побочных эффектов нет.

// adapters/out/sber/sber_adapter.go

func (a *SberAdapter) GetPaymentStatus(ctx context.Context, ref PaymentRef) (PaymentStatus, error) {
    var result PaymentStatus
    err := retry.Do(
        func() error {
            var callErr error
            result, callErr = a.doGetStatus(ctx, ref)
            return callErr
        },
        retry.Context(ctx),
        retry.Attempts(3),
        retry.DelayType(retry.BackOffDelay),
        retry.Delay(200*time.Millisecond),
        retry.RetryIf(isRetriable),
    )
    return result, err
}

Если первый вызов получил transient 503, второй попробует ещё раз — максимум потратит ~600ms лишних.

Случай 2: write с Idempotency-Key

Если внешняя система обязалась дедуплицировать по ключу, retry безопасен — повторный запрос с тем же ключом вернёт тот же результат без второго эффекта.

// adapters/out/sber/sber_adapter.go

func (a *SberAdapter) Register(ctx context.Context, order Order) (PaymentRef, error) {
    req := toRegisterRequest(order)
    req.IdempotencyKey = order.IdempotencyKey // детерминированный UUID v5/v7

    var result PaymentRef
    err := retry.Do(
        func() error {
            var callErr error
            result, callErr = a.doRegister(ctx, req)
            return callErr
        },
        retry.Context(ctx),
        retry.Attempts(3),
        retry.DelayType(retry.BackOffDelay),
        retry.Delay(200*time.Millisecond),
        retry.RetryIf(isRetriable),
    )
    return result, err
}

Что критично:

  • IdempotencyKeyдетерминированный: UUID v5 из orderId + operation или UUID v7, сгенерированный один раз на user-action и сохранённый.
  • Внешняя система обязана гарантировать дедуп. Если гарантия не прописана в её контракте — retry на write запрещён, даже с ключом.
  • TTL ключа на стороне внешней системы должен быть больше суммарного окна retry (включая task-queue).

RetryIf — фильтр только транзиентных ошибок

Без retry.RetryIf пакет ретраит всё подряд: 4xx, gobreaker.ErrOpenState, доменные ошибки. Это неверно.

// adapters/out/sber/retry.go

func isRetriable(err error) bool {
    if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
        return false
    }
    var unavail *PaymentSystemUnavailableError
    if errors.As(err, &unavail) {
        return false
    }
    var netErr net.Error
    if errors.As(err, &netErr) && netErr.Timeout() {
        return true
    }
    return isRetriable5xx(err)
}

func isRetriable5xx(err error) bool {
    var httpErr *HTTPStatusError
    if errors.As(err, &httpErr) {
        return httpErr.StatusCode >= 500
    }
    return false
}

Логика фильтра:

  • ErrOpenState / ErrTooManyRequests от gobreaker — CB уже принял решение; ретраить нет смысла и вредно (double-count в failure rate CB).
  • PaymentSystemUnavailableError (маппинг из CB) — то же.
  • net.Error.Timeout() — timeout транзиентен: сетевой hiccup, под перезапускается.
  • HTTPStatusError 5xx от внешней системы — транзиент.
  • 4xx — не ретраить: это наша ошибка (неверный запрос, auth, not-found).

Конфиг retry

R-RES-RE-2/R-RES-RE-3: типовой набор опций для retry.Do.

// adapters/out/receipt/receipt_adapter.go

func (a *ReceiptAdapter) GetReceiptStatus(ctx context.Context, ref ReceiptRef) (ReceiptStatus, error) {
    var status ReceiptStatus
    err := retry.Do(
        func() error {
            var callErr error
            status, callErr = a.doGetReceiptStatus(ctx, ref)
            return callErr
        },
        retry.Context(ctx),          // уважает ctx.Done() — отмена прерывает retry
        retry.Attempts(3),           // 3 попытки включая первую (R-RES-RE-3)
        retry.DelayType(retry.BackOffDelay), // экспоненциальный backoff (R-RES-RE-X3)
        retry.Delay(200*time.Millisecond),   // первая пауза 200ms → 400ms → 800ms
        retry.MaxDelay(5*time.Second),       // потолок паузы
        retry.RetryIf(isRetriable),          // только транзиенты
    )
    return status, err
}

Математика задержек при Attempts(3), Delay(200ms), BackOffDelay:

  • попытка 1 → ошибка → пауза 200ms
  • попытка 2 → ошибка → пауза 400ms
  • попытка 3 → финальный результат

Суммарное время между попытками ≈ 600ms. Плюс 3 × callTimeout (если каждая попытка идёт до таймаута). Это вписывается в транзиентное окно <5s.

Attempts(3) — типовое, 5 — предел

R-RES-RE-3: больше 5 попыток — это уже задача для task-queue.

  • 3 попытки покрывают transient hiccup: connection reset, pod внешней системы перезапускается (1–2 секунды в k8s).
  • 5 попыток — для нестабильных систем с высоким baseline ошибок.
  • 10+ попыток — нет: ситуация сигнализирует о деградации внешней системы. Нужно либо открыть CB и уйти в fallback, либо положить задачу в task-queue.

Граница in-memory retry vs task-queue

R-RES-RE-4: критический порог — около 5 секунд суммарного in-memory времени.

Длительность отказаИнструментПочему
<5s (transient)In-memory retry.DoБыстро, не нагружает БД
5–30sОбычно task-queueSync-блок handler-а на 30s = timeout upstream
>30s (durable failure)Task-queue с DB-driven schedulerПереживает рестарт сервиса, не блокирует горутины

In-memory retry на 30s = горутины в зомби-ожидании: при нагрузке семафор bulkhead исчерпывается из-за висящих retry, а не из-за внешней системы.

Task-queue retry

R-RES-RE-5: durable retry — через таблицу БД с polling-scheduler.

// scheduler/order_confirm_scheduler.go

func (s *OrderConfirmScheduler) Run(ctx context.Context) {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            s.processDue(ctx)
        }
    }
}

func (s *OrderConfirmScheduler) processDue(ctx context.Context) {
    tasks, err := s.repo.FindDue(ctx, 50) // FOR UPDATE SKIP LOCKED
    if err != nil {
        slog.Error("order confirm scheduler: find due", "err", err)
        return
    }
    for _, t := range tasks {
        if err := s.adapter.Confirm(ctx, t); err != nil {
            next := t.RetryCount + 1
            if next >= 10 {
                _ = s.repo.MarkFailed(ctx, t.TaskID, err.Error())
                s.alertOps(t)
            } else {
                backoff := time.Duration(math.Pow(2, float64(next))) * time.Second
                _ = s.repo.ScheduleRetry(ctx, t.TaskID, next, time.Now().Add(backoff), err.Error())
            }
            continue
        }
        _ = s.repo.MarkCompleted(ctx, t.TaskID)
    }
}

Поля таблицы задач: status, retry_count, next_attempt_at, last_error. Подробно — в Async и polling.

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

АнтипаттернПравилоЧто взамен
retry.Do на write-методе без Idempotency-KeyR-RES-RE-X1Либо IdempotencyKey в запросе, либо без retry
Ретраить 4xx от внешней системыR-RES-RE-X2В RetryIfreturn false для 4xx
retry.FixedDelay без роста задержкиR-RES-RE-X3retry.BackOffDelay с retry.Delay
Ретраить gobreaker.ErrOpenStateR-RES-RE-X4В RetryIferrors.Is(err, gobreaker.ErrOpenState)false
retry.Attempts > 5 для in-memoryR-RES-RE-3Task-queue (*_task таблица)
In-memory retry для отказов >30sR-RES-RE-4Task-queue (durable, переживает рестарт)
retry.Do без retry.Context(ctx)R-RES-RE-2retry.Context(ctx) чтобы отмена прерывала retry

Куда дальше

  • Async и polling — task-queue для долгих отказов и polling внешних систем.
  • Circuit Breaker — fast-fail когда retry не помогает; интеграция с gobreaker.
  • Bulkhead — семафор защищает горутины от исчерпания в период retry-шторма.
  • Fallback — что делать когда retry исчерпан.
  • Конфигурация — декларативный конфиг через envconfig.
  • Timeouts — capTimeout уважает входящий дедлайн контекста.
  • Где какая защита — карта: что на out-adapter, что на task-queue.
  • Health checks — TTL-кеш probe не путать с retry.
  • Observability — метрики retry_attempts_total{system, outcome}.
  • OpenAPI generator binding — retry.Do на public-методе адаптера, не на сгенерированном клиенте.
  • Per-system isolation — отдельный *http.Client и конфиг retry per-system.