Опирается на правила:
R-RES-RE-1…R-RES-RE-5иR-RES-RE-X1…R-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, под перезапускается.HTTPStatusError5xx от внешней системы — транзиент.- 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-queue | Sync-блок 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-Key | R-RES-RE-X1 | Либо IdempotencyKey в запросе, либо без retry |
| Ретраить 4xx от внешней системы | R-RES-RE-X2 | В RetryIf — return false для 4xx |
retry.FixedDelay без роста задержки | R-RES-RE-X3 | retry.BackOffDelay с retry.Delay |
Ретраить gobreaker.ErrOpenState | R-RES-RE-X4 | В RetryIf — errors.Is(err, gobreaker.ErrOpenState) → false |
retry.Attempts > 5 для in-memory | R-RES-RE-3 | Task-queue (*_task таблица) |
| In-memory retry для отказов >30s | R-RES-RE-4 | Task-queue (durable, переживает рестарт) |
retry.Do без retry.Context(ctx) | R-RES-RE-2 | retry.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.