Ретраи и бэкофф в Go: как делать правильно
Наивный цикл с time.Sleep умеет добить упавший сервис лавиной синхронных повторов. Разбираем, чем экспоненциальный бэкофф с джиттером лучше фиксированной паузы, что повторять безопасно, а что нельзя, и как не забыть про context.

Задача звучит просто: HTTP-запрос к соседнему сервису иногда возвращает 503, надо повторить. Первое, что пишут, — цикл с time.Sleep(time.Second) и тремя попытками. Такой код проходит ревью, работает в тестах и убивает продакшн в первый же серьёзный сбой: сотни клиентов синхронно бьют в один момент по уже перегруженному сервису, каждый ещё и по три раза. Это называется retry storm, и лечится он не увеличением паузы, а правильной стратегией. Разберём, как строить ретраи в Go так, чтобы они помогали, а не добивали.
Материал для Go 1.22+. Примеры используют только стандартную библиотеку и golang.org/x/time/rate там, где нужен лимитер. Готовые пакеты вроде github.com/cenkalti/backoff/v4 упомяну, но сначала важно понять, что именно они делают за вас.
Что вообще можно повторять
Главная ошибка — ретраить всё подряд. Повтор безопасен только для идемпотентных операций: тех, где N одинаковых запросов дают тот же результат, что и один. GET, PUT, DELETE идемпотентны по определению HTTP. POST — нет: повтор создания заказа может создать два заказа.
Ретрай без идемпотентности — это не отказоустойчивость, а генератор дубликатов. Прежде чем оборачивать вызов в цикл, ответьте на вопрос: что будет, если он выполнится дважды.
Что стоит повторять, а что нет:
- Повторяем: сетевые таймауты,
connection refused, 502/503/504, 429 (Too Many Requests) — но с уважением кRetry-After. - Не повторяем: 400, 401, 403, 404, 422 — тело запроса или права не изменятся от повтора, вы просто тратите время и нагрузку.
- Осторожно: 500 без деталей и оборванное соединение на POST — сервер мог успеть обработать запрос. Здесь нужен ключ идемпотентности, а не слепой повтор.
Для POST многие API принимают заголовок Idempotency-Key — уникальный идентификатор операции. Сервер запоминает его и на повторе возвращает первый результат вместо создания дубликата. Если API это поддерживает, POST становится безопасным для ретрая.
Почему фиксированная пауза плоха
Фиксированный Sleep(1s) между попытками создаёт два эффекта. Первый — синхронизация: если сервис упал на секунду, все клиенты, ждавшие ровно секунду, ударят одновременно. Второй — недостаточная адаптация: сервису, которому нужно 10 секунд на восстановление, ваши три попытки по секунде не помогут, а нагрузку добавят.
Экспоненциальный бэкофф решает первую проблему: задержка растёт как base * 2^attempt. Джиттер (случайный разброс) решает вторую: клиенты перестают бить в такт. Без джиттера экспонента лишь сдвигает лавину, но не рассеивает её.
Сравнение стратегий
| Стратегия | Задержки (base=100ms) | Плюс | Минус |
|---|---|---|---|
| Фиксированная | 100, 100, 100 | предсказуема | синхронизирует клиентов |
| Экспоненциальная | 100, 200, 400 | щадит сервис | без джиттера — та же лавина, сдвинутая |
| Экспонента + full jitter | rand[0,100], rand[0,200], rand[0,400] | рассеивает нагрузку | иногда почти нулевая пауза |
| Экспонента + equal jitter | 50+rand[0,50], 100+rand[0,100], ... | баланс паузы и разброса | чуть сложнее формула |
На практике в AWS-архитектурах прижился именно full jitter — случайная задержка в диапазоне [0, base*2^attempt]. Он даёт лучшее рассеивание при большом числе клиентов. Equal jitter выбирают, когда нужна гарантия минимальной паузы между попытками.
Реализация с бэкоффом, джиттером и context
Собственная функция ретрая на стандартной библиотеке. Ключевые моменты: context должен прерывать ожидание, а не только сам вызов; задержку ограничиваем потолком (cap); ошибки классифицируем, а не повторяем вслепую.
package retry
import (
"context"
"errors"
"math"
"math/rand"
"time"
)
// permanentError оборачивает ошибку, которую повторять не нужно.
type permanentError struct{ err error }
func (p permanentError) Error() string { return p.err.Error() }
func (p permanentError) Unwrap() error { return p.err }
// Permanent помечает ошибку как неповторяемую (например, 400 или 401).
func Permanent(err error) error { return permanentError{err} }
type Config struct {
MaxAttempts int // сколько всего попыток, включая первую
BaseDelay time.Duration // стартовая задержка
MaxDelay time.Duration // потолок задержки
}
// Do вызывает op, пока она не вернёт nil, permanentError или не кончатся попытки.
func Do(ctx context.Context, cfg Config, op func(ctx context.Context) error) error {
var lastErr error
for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
lastErr = op(ctx)
if lastErr == nil {
return nil
}
// Неповторяемую ошибку возвращаем сразу.
var perm permanentError
if errors.As(lastErr, &perm) {
return perm.err
}
// После последней попытки не ждём.
if attempt == cfg.MaxAttempts-1 {
break
}
delay := backoff(attempt, cfg.BaseDelay, cfg.MaxDelay)
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return lastErr
}
// backoff считает full jitter: случайное значение в [0, base*2^attempt], но не выше max.
func backoff(attempt int, base, max time.Duration) time.Duration {
exp := float64(base) * math.Pow(2, float64(attempt))
if exp > float64(max) {
exp = float64(max)
}
return time.Duration(rand.Int63n(int64(exp) + 1))
}
Обратите внимание на select с ctx.Done(). Если вместо этого написать time.Sleep(delay), отмена контекста не прервёт ожидание — горутина проспит паузу целиком, даже когда клиент уже отменил запрос. Это частая ошибка: Sleep не знает про context.
Классификация HTTP-ответов
Функция op должна сама решать, повторяема ли ошибка, и оборачивать неповторяемые в Permanent:
func fetch(ctx context.Context, client *http.Client, url string) func(context.Context) error {
return func(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return retry.Permanent(err) // кривой URL повторять бессмысленно
}
resp, err := client.Do(req)
if err != nil {
return err // сетевую ошибку повторяем
}
defer resp.Body.Close()
switch {
case resp.StatusCode < 300:
return nil
case resp.StatusCode == 429 || resp.StatusCode >= 500:
return fmt.Errorf("retryable status %d", resp.StatusCode)
default:
return retry.Permanent(fmt.Errorf("status %d", resp.StatusCode))
}
}
}
Чего этот код ещё не умеет
Есть три вещи, которые в наивной реализации обычно забывают, а в проде они решают.
- Retry-After. Ответ 429 или 503 может нести заголовок
Retry-Afterс числом секунд или датой. Уважайте его — сервер прямо говорит, когда к нему возвращаться. Ваш бэкофф в этом случае должен уступить значению из заголовка. - Общий бюджет времени.
MaxAttemptsбез общего дедлайна опасен: пять попыток с потолком в 30 секунд могут растянуться на минуты. Ставьтеcontext.WithTimeoutна весь ретрай, а не только на один вызов. - Circuit breaker. Ретраи и предохранитель решают разные задачи. Ретрай борется с редким единичным сбоем, breaker — с длительной недоступностью: он перестаёт слать запросы вовсе, давая сервису восстановиться. Под нагрузкой нужны оба.
Если сервис лежит, а вы продолжаете ретраить каждый запрос, вы усиливаете шторм. Circuit breaker размыкает цепь после серии ошибок и возвращает ошибку мгновенно, не нагружая упавший бэкенд. Настройка предохранителя — отдельная тема, здесь важно лишь помнить, что ретрай без него под настоящей нагрузкой недостаточен.
Когда брать готовый пакет
Своя функция на 40 строк оправдана, если поведение простое и предсказуемое. Как только появляются требования вроде «уважать Retry-After», «разные политики для разных эндпоинтов», «метрики по попыткам» — смотрите в сторону библиотек.
github.com/cenkalti/backoff/v4 даёт готовые стратегии (ExponentialBackOff), интеграцию с context и обёртку backoff.Permanent для неповторяемых ошибок — ровно та же идея, что в примере выше. Проверьте актуальный API в README репозитория: сигнатуры между мажорными версиями менялись, и слепо копировать примеры из старых постов не стоит.
Что настроить дальше
Три показателя, за которыми стоит следить в проде после внедрения ретраев:
- Доля повторов от общего числа запросов. Рост говорит, что зависимость деградирует — ретраи маскируют проблему, но не решают её.
- Распределение числа попыток на успешный запрос. Если многие успевают только с третьей — потолок задержки или число попыток подобраны неудачно.
- Латентность p99 с учётом ретраев. Один запрос с тремя повторами может уложиться в бюджет, а может выесть весь дедлайн клиента.
Следующий шаг — добавить общий дедлайн через context.WithTimeout, научить op читать Retry-After и обернуть вызовы к нестабильным зависимостям в circuit breaker. И проверьте главное: каждая операция, которую вы повторяете, идемпотентна или несёт ключ идемпотентности.
Инфографика для маркетплейса: Три главных преимущества товара короткими фразами, выноски в безопасной зоне превью, точный размер площадки.
Спросить за 60 ₽Источники: Exponential Backoff And Jitter — AWS Architecture Blog, cenkalti/backoff — GitHub
Частые вопросы
У меня Go 1.20, будет ли работать пример?
Почему select с timer, а не просто time.Sleep?
Сколько попыток и какой потолок ставить?
Что если ретраи не помогают, ошибки продолжаются?
Можно ли ретраить POST-запросы?
Нужен ли джиттер, если клиент всего один?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.