Retry и Circuit Breaker
Deep-dive по паттернам обработки временных отказов при вызовах внешних
зависимостей: HTTP-клиенты к другим сервисам, Redis, Kafka, S3, БД. Reference
по middleware-стеку consumer’а — ../conventions/events.
Эта страница отвечает на вопрос: как правильно повторять запросы, когда
отступать, и как защитить сервис от каскадного падения downstream.
Содержание
- Когда это нужно
- Когда НЕ нужно
- Retry: exponential backoff + jitter
- Time budget и ctx.Deadline
- Что можно retry’ить
- Идемпотентность — обязательное условие
- Budget и deadline
- Retry для HTTP-клиента
- Retry для pgx
- Retry для Kafka consumer
- Circuit Breaker
- Combined: circuit breaker + retry
- Fallback и graceful degradation
- Monitoring
- Testing
- Anti-patterns
- FAQ
- Связанные разделы
Когда это нужно
Любой сетевой вызов может упасть без причины со стороны кода: dropped packet, TCP RST, коротковременный 5xx от downstream, Kafka broker в процессе leader election. Без retry один такой отказ превращается в ошибку для конечного клиента, хотя повтор через 200ms прошёл бы успешно.
Retry обязателен:
- HTTP-клиенты к другим нашим сервисам (
user,review,media,notification) и к внешним API. - Kafka consumer handler’ы — уже покрыто
middleware.Retryв стеке router’а (../conventions/events). - S3 / MinIO через
aws-sdk-go-v2— retry включён в SDK по умолчанию, настраивается черезRetryMode. - Redis — в
go-redisretry на уровне отдельной команды по умолчанию включён (MaxRetries = 3).
Circuit breaker (CB) — отдельный паттерн, работает поверх retry или вместо него. Он нужен, когда retry на каждом запросе только усугубляет ситуацию: downstream уже лежит, и 5 реплик твоего сервиса, каждая делающая 3 retry, генерируют 15× нагрузку на уже умирающий сервис. CB на consumer-стороне временно «размыкает» цепь — запросы возвращают ошибку мгновенно, без попытки дозвониться.
Когда НЕ нужно
- Синхронный user-facing HTTP-endpoint с жёстким SLA — retry внутри
handler’а съедает time-budget. Лучше сразу 503 + retry на клиентской
стороне (мобильное приложение через
exponential-backoffбиблиотеку). - БД-транзакция. Retry транзакций — отдельная история:
serialization_failure(40001) иdeadlock_detected(40P01) — retryable, всё остальное — нет. См. §Retry для pgx. - Внутри
InTx. Повторный вызов внешней системы внутри транзакции держит lock на строках дольше — это блокирует чужие запросы. Retry только вокруг транзакции, не внутри. - Идемпотентность не гарантирована. Если у тебя
POST /paymentsбез idempotency key — retry удвоит платёж. Сначала идемпотентность, потом retry. См. §Идемпотентность. - Ошибка постоянная. 400 Bad Request, 404 Not Found, 422 Unprocessable
Entity,
context.Canceled,ErrNotFoundиз service-слоя — retry не поможет. См. §Что можно retry’ить.
Retry: exponential backoff + jitter
Стандартная формула интервала между попытками:
delay(n) = min(max_interval, initial_interval * multiplier^n) + random(0, jitter)Значения по умолчанию для HTTP-клиентов между нашими сервисами:
| Параметр | Значение | Почему |
|---|---|---|
max_attempts | 3 | Total budget ≤ ~6s при max_interval=2s. Больше — съедает SLA endpoint’а. |
initial_interval | 100ms | Покрывает TCP RTO, leader election Kafka, 99-й перцентиль latency. |
multiplier | 2.0 | Экспоненциальное отступление: 100ms → 200ms → 400ms. |
max_interval | 2s | Cap, чтобы длинный retry не затянул один запрос на минуты. |
jitter | ±25% | Размазывает «thundering herd», когда 100 клиентов получили отказ в один момент. |
Для Kafka consumer (background-worker) budget другой —
../conventions/events
уже задаёт MaxRetries=5, InitialInterval=500ms, MaxInterval=30s. Там
нет клиента с таймером — можно позволить себе более долгие попытки.
Jitter обязателен. Без него два клиента, получившие отказ в одну миллисекунду, повторят запрос одновременно — и одновременно же получат следующий отказ. С jitter ±25% они разъедутся во времени и не нагрузят downstream синхронно.
Выбор ±25% — компромисс между двумя крайностями. Google SRE
Book/AWS Architecture Blog («Exponential Backoff and Jitter», Marc
Brooker) показывают, что диапазон [0, backoff] (full jitter)
лучше размазывает нагрузку, но ломает предсказуемость latency для
пользователя (90-й перцентиль скачет в 2× на границах попыток).
Диапазон ±10% практически не спасает от thundering herd при
синхронных массовых отказах. ±25% — усреднённый практический
выбор, совпадает с default’ом cenkalti/backoff/v4
(RandomizationFactor = 0.5 → [0.5*b, 1.5*b], что эквивалентно
«b ± 25% от центра»). Менять per-downstream — только с явным
обоснованием, зафиксированным в pkg/httpclient/ того сервиса.
Используем github.com/cenkalti/backoff/v4 для клиентской логики — это
стандарт в экосистеме Go, та же библиотека под капотом у Watermill-retry.
Time budget и ctx.Deadline
Retry без учёта контекстного дедлайна легко становится бесполезным.
Рассмотрим: три попытки с backoff’ами 100 / 200 / 400 мс и самими
запросами по 500 мс каждый. Суммарный wall-time:
500 + 100 + 500 + 200 + 500 + 400 = 2200 мс ≈ 2.3 секунды. Если
handler-timeout выставлен в 2 секунды, клиент получит 504 Gateway Timeout до того, как мы успеем сделать третью попытку — retry-бюджет
был больше, чем оставалось времени у handler’а.
Правило: каждая попытка выделяет себе equal share оставшегося
бюджета. Если у нас 3 попытки и ctx оставшееся время remaining —
на одну попытку идёт remaining / attemptsLeft. После неудачи,
attemptsLeft уменьшается, remaining тоже (потратили время на
попытку и на backoff-паузу) — следующая попытка пересчитывает свой
бюджет.
Формула:
// attemptTimeout возвращает per-attempt timeout как честную долю
// оставшегося бюджета ctx. 0 возвращается, когда бюджета уже нет.
func attemptTimeout(ctx context.Context, attemptsLeft int) time.Duration {
dl, ok := ctx.Deadline()
if !ok {
return 0 // нет дедлайна — нет ограничения, но это анти-паттерн
}
remaining := time.Until(dl)
if remaining <= 0 {
return 0
}
return remaining / time.Duration(attemptsLeft)
}Полный пример:
package retry
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
)
// DoWithRetry выполняет fn до maxAttempts раз, честно распределяя
// оставшийся бюджет ctx между попытками.
func DoWithRetry(ctx context.Context, maxAttempts int, fn func(context.Context) error) error {
var lastErr error
backoff := 100 * time.Millisecond
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return err // немедленный выход без retry
}
attemptsLeft := maxAttempts - attempt + 1
perAttempt := attemptTimeout(ctx, attemptsLeft)
if perAttempt <= 0 {
return fmt.Errorf("retry budget exceeded: %w", lastErr)
}
perAttemptCtx, cancel := context.WithTimeout(ctx, perAttempt)
lastErr = fn(perAttemptCtx)
cancel()
if lastErr == nil {
return nil
}
if !IsRetryable(nil, lastErr) {
return lastErr
}
if attempt == maxAttempts {
break
}
// Jitter на backoff.
jitter := time.Duration(rand.Int63n(int64(backoff) / 4))
sleep := backoff + jitter
// Если backoff > оставшейся доли на следующую попытку — пропускаем.
remaining := time.Until(mustDeadline(ctx))
nextShare := remaining / time.Duration(attemptsLeft-1)
if sleep > nextShare {
return fmt.Errorf("retry budget exceeded (backoff %s > share %s): %w",
sleep, nextShare, lastErr)
}
select {
case <-time.After(sleep):
case <-ctx.Done():
return ctx.Err()
}
backoff *= 2
}
return lastErr
}
func mustDeadline(ctx context.Context) time.Time {
dl, _ := ctx.Deadline()
return dl
}Разбор по частям:
- Перед каждой попыткой — проверка
ctx.Err(). Если handler сверху отменён — немедленный выход без попытки и backoff’а. - per-attempt ctx —
context.WithTimeout(ctx, attemptTimeout(ctx, attemptsLeft)). Корневойctxостаётся с его дедлайном, но каждая попытка имеет свой sub-deadline, честную долю от оставшегося. cancel()сразу после попытки — не через defer в цикле, иначе накопится goroutine leak.- Backoff сверяется с бюджетом следующей попытки. Если jitter +
exponential backoff требует больше времени, чем останется на
следующую попытку, ретрай уже не имеет смысла — возвращаем ошибку
сразу, не ждём в
time.After.
Правила:
- Handler, вызывающий retry, ВСЕГДА передаёт ctx с дедлайном. Без
дедлайна retry может занять минуты на висящем downstream.
attemptTimeoutвозвращает 0 → ошибка → ранний выход. - Jitter применяется к backoff, но если
backoff > remaining / attemptsLeft, попытка пропускается — нет смысла тратить оставшийся бюджет на sleep, если на саму попытку ничего не останется. - Метрика
retry_budget_exceeded_total{downstream}— counter, сколько раз мы прервали retry-цикл из-за исчерпания бюджета (отдельно отretry_exhausted, когда кончились попытки без бюджета). Рост этой метрики — сигнал, что либо handler-timeout слишком мал, либо downstream стал медленнее.
Распределение бюджета на примере
ctx.Deadline = 2s, maxAttempts = 3, per-downstream p99 = 400ms.
| Попытка | Remaining на старте | per-attempt = remaining / left | Итог попытки | После попытки |
|---|---|---|---|---|
| 1 | 2000 ms | 2000 / 3 ≈ 666 ms | failure за 400 ms | remaining ≈ 1600 ms, backoff sleep 100 ms |
| 2 | 1500 ms | 1500 / 2 = 750 ms | failure за 400 ms | remaining ≈ 1100 ms, backoff sleep 200 ms |
| 3 | 900 ms | 900 / 1 = 900 ms | success за 400 ms | — |
Если же первая попытка длилась дольше (скажем, 800 ms из-за latency
spike), remaining после неё = 1200 ms, на вторую попытку даём 600 ms,
backoff 100 ms, на третью остаётся 500 ms — всё ещё помещается. Если
первая попытка заняла 1500 ms (вышла за perAttempt 666 ms — тут
перешли бы в timeout раньше), следующий attemptTimeout вернёт
оставшиеся 500 / 2 = 250 ms, что меньше p99 downstream → скорее всего
будет timeout → третья попытка уже не запустится из-за budget
exceeded.
Вывод: при reasonable ratio deadline / (maxAttempts × p99) ≥ 2 схема
работает без drop’ов. Если коэффициент падает к 1 — либо увеличь
deadline, либо уменьши maxAttempts.
Что можно retry’ить
Retry имеет смысл только для transient-ошибок. Классификация:
| Категория | Retryable? | Примеры |
|---|---|---|
| Сетевые | да | context.DeadlineExceeded от внутреннего таймаута, net.ErrClosed, EOF при чтении body, connection reset by peer, no such host (может быть DNS-глюк) |
| 5xx от downstream | да | 500, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout |
| 429 Too Many Requests | да, но с Retry-After | downstream просит притормозить — уважай заголовок |
| 4xx (кроме 429) | нет | 400/401/403/404/422 — ошибка запроса, повтор даст тот же результат |
| 200–2xx | нет | успех |
context.Canceled | нет | клиент уже отменил запрос, retry — пустая работа |
SQL serialization_failure (40001) | да | конфликт snapshot isolation, повтор транзакции — штатный сценарий |
SQL deadlock_detected (40P01) | да | Postgres сам откатил одну из транзакций, повтор может пройти |
SQL foreign_key_violation (23503), unique_violation (23505) | нет | бизнес-ошибка, retry не поможет |
Helper для классификации HTTP-ответа:
package retry
import (
"context"
"errors"
"net/http"
)
// IsRetryable возвращает true, если ошибку/ответ имеет смысл повторить.
//
// Scope: **HTTP REST-клиенты**. Для pgx использовать
// isSerializationFailure (§Retry для pgx), для Kafka — уже встроено
// в watermill middleware.Retry. Для gRPC (в проекте не используется)
// нужен свой mapper на codes.Unavailable/DeadlineExceeded.
func IsRetryable(resp *http.Response, err error) bool {
if errors.Is(err, context.Canceled) {
return false
}
if err != nil {
// Сетевые ошибки чаще всего transient: dial timeout, TLS
// handshake timeout, EOF при чтении body, RST. Если транспорт
// возвращает структурно non-retryable (например, TLS
// certificate verification failed — это постоянная ошибка),
// клиент может обернуть в backoff.Permanent перед Retry.
return true
}
switch resp.StatusCode {
case http.StatusRequestTimeout, // 408
http.StatusTooManyRequests, // 429
http.StatusInternalServerError, // 500
http.StatusBadGateway, // 502
http.StatusServiceUnavailable, // 503
http.StatusGatewayTimeout: // 504
return true
default:
return false
}
}Эта функция — единый источник правды для HTTP-retry. Не дублируй её в каждом клиенте; транспорт-специфичные классификаторы (pgx, gRPC при появлении) держи рядом, но не поверх этой функции.
Идемпотентность — обязательное условие
Retry удваивает запрос при частичных отказах: клиент повторил, потому что прочитал timeout, а сервер успел обработать первый запрос. Если handler не идемпотентен — в БД появляются дубли.
Правила:
- GET, HEAD, OPTIONS — идемпотентны по HTTP-семантике, retry всегда безопасен.
- PUT, DELETE — должны быть идемпотентны по контракту (повторный PUT с тем же body не меняет состояние).
- POST — не идемпотентен по умолчанию. Чтобы разрешить retry,
клиент передаёт
Idempotency-Keyв header, сервер хранит(key → response)в Redis с TTL 24h и на повторный запрос отдаёт закешированный ответ без выполнения операции.
Схема для internal endpoint’ов, принимающих POST от других сервисов:
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" {
writeError(w, http.StatusBadRequest, "missing_idempotency_key",
"Idempotency-Key header is required")
return
}
if cached, ok := h.cache.Get(r.Context(), "idem:"+key); ok {
w.Header().Set("X-Idempotency-Replay", "true")
w.WriteHeader(cached.Status)
_, _ = w.Write(cached.Body)
return
}
// ... обычный процессинг ...
h.cache.Set(r.Context(), "idem:"+key, cachedResponse{Status: 201, Body: body}, 24*time.Hour)
}Для Kafka consumer’ов идемпотентность обеспечивается Deduplicator
middleware + уникальный constraint на БД — см.
idempotent-consumer.
Budget и deadline
Retry существует внутри общего time-budget запроса. Budget приходит в
ctx и обязан уважаться:
func (c *UserClient) Get(ctx context.Context, id int64) (*User, error) {
var u User
op := func() error {
resp, err := c.http.Get(ctx, fmt.Sprintf("/internal/users/%d", id))
if err != nil { return err }
defer resp.Body.Close()
if !retry.IsRetryable(resp, nil) && resp.StatusCode >= 400 {
return backoff.Permanent(httpError(resp))
}
if resp.StatusCode >= 500 {
return httpError(resp)
}
return json.NewDecoder(resp.Body).Decode(&u)
}
policy := backoff.WithContext(
backoff.NewExponentialBackOff(
backoff.WithInitialInterval(100*time.Millisecond),
backoff.WithMaxInterval(2*time.Second),
backoff.WithMultiplier(2.0),
backoff.WithRandomizationFactor(0.25),
backoff.WithMaxElapsedTime(5*time.Second),
),
ctx,
)
if err := backoff.Retry(op, policy); err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return &u, nil
}Ключевое:
backoff.WithContext(..., ctx)— еслиctxотменится (например, HTTP- handler выше получил Timeout middleware), retry остановится немедленно.backoff.Permanent(err)— обёртка для non-retryable ошибок: библиотека прервёт цикл, даже еслиmax_attemptsне исчерпан. Без неё 400 Bad Request будут повторяться 3 раза впустую.WithMaxElapsedTime— absolute cap на всю retry-цепочку, не на одну попытку.
Правило большого пальца: ctx с deadline 30s на handler → retry budget
≤ 5s на один downstream-вызов. Если у тебя 3 последовательных вызова,
суммарно ≤ 15s, остаётся 15s на БД и собственную логику.
Retry для HTTP-клиента
Обёртка над *http.Client, которая добавляет retry прозрачно. Живёт в
pkg/httpclient/ каждого сервис-репо:
package httpclient
import (
"bytes"
"fmt"
"io"
"net/http"
"time"
"github.com/cenkalti/backoff/v4"
)
type RetryClient struct {
base *http.Client
policy backoff.BackOff
retryIf func(*http.Response, error) bool
}
func NewRetryClient(base *http.Client, maxElapsed time.Duration) *RetryClient {
b := backoff.NewExponentialBackOff()
b.InitialInterval = 100 * time.Millisecond
b.MaxInterval = 2 * time.Second
b.Multiplier = 2.0
b.RandomizationFactor = 0.25
b.MaxElapsedTime = maxElapsed
return &RetryClient{
base: base,
policy: b,
retryIf: IsRetryable,
}
}
func (c *RetryClient) Do(req *http.Request) (*http.Response, error) {
var body []byte
if req.Body != nil {
b, err := io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
body = b
}
var resp *http.Response
op := func() error {
if body != nil {
req.Body = io.NopCloser(bytes.NewReader(body))
}
r, err := c.base.Do(req)
if err != nil {
if !c.retryIf(nil, err) {
return backoff.Permanent(err)
}
return err
}
if !c.retryIf(r, nil) {
resp = r
return nil
}
// retryable status — закрыть body, вернуть ошибку
_ = r.Body.Close()
return fmt.Errorf("http %d", r.StatusCode)
}
if err := backoff.Retry(op, backoff.WithContext(c.policy, req.Context())); err != nil {
return nil, err
}
return resp, nil
}Важные детали:
- Тело запроса читается один раз. Если
req.Body— поток, на втором retry он будет пустой.RetryClient.Doбуферизует body в[]byteи подставляет свежийio.NopCloserна каждой попытке. - Response body закрывается между попытками. Leak goroutine на
resp.Body— типовой баг. context.Canceled→backoff.Permanent— не retry’им отменённый запрос.
Клиенты конкретных downstream-сервисов строятся поверх RetryClient:
userClient := &UserClient{http: httpclient.NewRetryClient(
&http.Client{Timeout: 5 * time.Second,
Transport: otelhttp.NewTransport(http.DefaultTransport)},
5 * time.Second,
)}otelhttp.NewTransport — обязательно. Retry происходит внутри одного
span’а (см. ../conventions/observability).
В Tempo видна цепочка попыток одного логического запроса.
Retry для pgx
Postgres возвращает специфические SQLSTATE-коды для transient-конфликтов. Retry на уровне транзакции:
import "github.com/jackc/pgx/v5/pgconn"
func (d *DB) InTxWithRetry(ctx context.Context, fn func(tx pgx.Tx) error) error {
const maxAttempts = 3
for attempt := 1; attempt <= maxAttempts; attempt++ {
err := d.InTx(ctx, fn)
if err == nil {
return nil
}
if !isSerializationFailure(err) || attempt == maxAttempts {
return err
}
// экспоненциальный backoff с jitter
sleep := time.Duration(10*attempt*attempt) * time.Millisecond
sleep += time.Duration(rand.Int63n(int64(sleep / 4)))
select {
case <-time.After(sleep):
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}
func isSerializationFailure(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
// Только два retryable-кода:
// 40001 — serialization_failure (snapshot-isolation конфликт
// на Serializable/Repeatable Read транзакциях).
// 40P01 — deadlock_detected (Postgres сам откатил одну из
// пересекающихся транзакций).
// Код 40000 (transaction_rollback) — не retryable: общий
// failure, обычно следствие логической ошибки или явного
// ROLLBACK. Код 40003 (statement_completion_unknown) — не
// retryable: непонятно, применилась ли операция, повтор
// может привести к дублю. Полный список — §Class 40 PostgreSQL
// SQLSTATE reference.
return pgErr.Code == "40001" || pgErr.Code == "40P01"
}Использование: d.InTxWithRetry(ctx, fn) — только если транзакция
идёт на Serializable isolation level или там где deadlock — штатный
сценарий (например, при concurrent update’ах одной строки). На обычных
ReadCommitted транзакциях retry не нужен.
Любой не-40001/40P01 код — bubble up, retry не делаем.
Retry для Kafka consumer
Уже покрыт middleware.Retry в стеке router’а. Настройка —
../conventions/events:
middleware.Retry{
MaxRetries: 5,
InitialInterval: 500 * time.Millisecond,
Multiplier: 2.0,
MaxInterval: 30 * time.Second,
Logger: watermillLogger,
}.Middleware,Ключевые особенности consumer-retry в отличие от HTTP-клиентского:
- Нет time-budget’а от клиента. Consumer работает в фоне — можно позволить себе 5 попыток до 30 секунд интервала.
- После исчерпания — PoisonQueue → DLQ. См.
../troubleshooting/kafka-consumer-stuckпро порядок middleware. - Задержка блокирует партицию. Пока handler retry’ит, другие
сообщения той же партиции не обрабатываются. Если
InitialInterval * 2^MaxRetries > max.poll.interval.ms→ rebalance → см.../troubleshooting/kafka-consumer-stuck.
Правило: если handler стабильно падает > 5 раз подряд на одном и том же сообщении — это не transient-ошибка. PoisonQueue отправит в DLQ, алерт сработает, разбирайся руками.
Circuit Breaker
Circuit breaker — state machine с тремя состояниями:
- Closed — штатный режим, запросы идут в downstream.
- Open — «цепь разомкнута», запросы не идут, возвращаем
ErrCircuitOpenмгновенно. Cooldown — 30 секунд по умолчанию. - HalfOpen — пропускаем один probe-запрос. Успех → Closed, провал → Open ещё на cooldown.
Используем github.com/sony/gobreaker/v2:
package userclient
import (
"github.com/sony/gobreaker/v2"
)
func NewCircuitBreaker(name string) *gobreaker.CircuitBreaker[*http.Response] {
return gobreaker.NewCircuitBreaker[*http.Response](gobreaker.Settings{
Name: name,
MaxRequests: 1, // в HalfOpen — 1 probe-запрос
Interval: 60 * time.Second, // окно для подсчёта failures в Closed
Timeout: 30 * time.Second, // cooldown в Open
ReadyToTrip: func(counts gobreaker.Counts) bool {
// trip: >= 20 запросов за окно и > 50% failure rate
if counts.Requests < 20 {
return false
}
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return failureRatio >= 0.5
},
OnStateChange: func(name string, from, to gobreaker.State) {
slog.Warn("circuit breaker state change",
"name", name, "from", from.String(), "to", to.String())
cbStateGauge.WithLabelValues(name, to.String()).Set(1)
},
})
}Обёртка вокруг downstream-клиента:
func (c *UserClient) Get(ctx context.Context, id int64) (*User, error) {
resp, err := c.breaker.Execute(func() (*http.Response, error) {
return c.retryClient.Do(buildReq(ctx, id))
})
if err != nil {
if errors.Is(err, gobreaker.ErrOpenState) {
return nil, fmt.Errorf("user service unavailable: %w", ErrDependencyDown)
}
return nil, err
}
defer resp.Body.Close()
return decodeUser(resp)
}ReadyToTrip подобран так, чтобы не тратить circuit на мелких спайках:
counts.Requests < 20— не открываем CB на первых 5 ошибках из 5 запросов (слишком мало данных). В low-traffic сервисе это означает, что CB редко активируется — и это правильно, там ручной фикс быстрее автоматической защиты.failure_ratio >= 0.5— половина запросов падает → downstream сломан → смысл бить его нет.
Параметры настраиваются per-клиент. Для критичного downstream (user, auth) — более жёсткие (0.3 / 10 запросов); для некритичного (media thumbnails) — более расслабленные (0.7 / 30 запросов).
Combined: circuit breaker + retry
Порядок слоёв: handler → CB → retry-client → http.Client.
Почему CB снаружи retry, а не наоборот:
- CB считает один логический запрос за одну попытку. Если retry внутри CB — 3 сетевых попытки считаются как один success/failure для CB. Это правильно: CB защищает downstream от избыточной нагрузки per request, а не per transport-attempt.
- Если CB открыт — retry бессмысленен. Мы мгновенно получаем
ErrOpenState, не ждём 100ms+200ms+400ms, чтобы узнать то же самое.
type UserClient struct {
retryClient *httpclient.RetryClient
breaker *gobreaker.CircuitBreaker[*http.Response]
}
func (c *UserClient) Get(ctx context.Context, id int64) (*User, error) {
resp, err := c.breaker.Execute(func() (*http.Response, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
c.baseURL+fmt.Sprintf("/internal/users/%d", id), nil)
req.Header.Set("X-Internal-Token", c.token)
return c.retryClient.Do(req)
})
// ...
}Fallback и graceful degradation
Что делать, когда CB открыт или все retry исчерпаны?
Вариантов три, в порядке предпочтения:
-
Cached stale data. Для read-запросов — отдаём предыдущее закешированное значение (см.
../conventions/caching). Отмечаем ответ заголовкомX-From-Cache: staleи логируем WARN.user, err := c.Get(ctx, id) if err != nil && errors.Is(err, ErrDependencyDown) { if stale, ok := c.cache.GetStale(ctx, id); ok { return stale, nil } } return user, err -
Partial response. Для list-view (api composition) — отдаём данные тех сервисов, которые ответили; поле от упавшего сервиса =
null/"unavailable". См.api-composition. -
503 клиенту. Когда cached/partial невозможны (write-операция, критичный источник данных) — fail fast с осмысленным сообщением. Клиент сам решит, retry или показать пользователю ошибку.
Правило: не показывай stub-данные без отметки. Пустой массив вместо «сервис лежит» скрывает инцидент от пользователя и от мониторинга.
Monitoring
Метрики
http_client_retries_total{service, target, attempt, result}
— counter, одна точка на каждую retry-попытку. result ∈ {success, retry, exhausted}.
http_client_request_duration_seconds{service, target, status}
— histogram, включая retry-паузы; считается по всему wall-time от
начала первой попытки до успеха/provaла.
circuit_breaker_state{service, target}
— gauge, 0=Closed, 1=HalfOpen, 2=Open. Скакание → инцидент.
circuit_breaker_trips_total{service, target}
— counter, сколько раз CB переходил Closed → Open.
dependency_errors_total{service, target, reason}
— counter, reason ∈ {timeout, 5xx, circuit_open, network}.Alerting
- CB часто trip’ится.
rate(circuit_breaker_trips_total[15m]) > 0.1→ ticket. Регулярные trip’ы = downstream-сервис нестабилен, разбирайся с его owner’ом. - CB открыт > 5 минут.
circuit_breaker_state > 1 and <gauge timestamp> > 5m→ page. Что-то реально сломано. - Retry exhausted rate растёт.
rate(http_client_retries_total{result="exhausted"}[5m]) > 1→ ticket. Либо downstream тормозит, либо taymeout слишком маленький.
Шаблоны alert-rule — ../how-to/add-metric-and-alert.
Testing
Unit: fake HTTP с контролируемыми отказами
func TestRetryClient_RetriesOn502(t *testing.T) {
var attempts int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 3 {
w.WriteHeader(http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id": 42}`))
}))
defer srv.Close()
c := httpclient.NewRetryClient(http.DefaultClient, 3*time.Second)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, nil)
resp, err := c.Do(req)
if err != nil { t.Fatalf("Do: %v", err) }
defer resp.Body.Close()
if attempts != 3 {
t.Fatalf("expected 3 attempts, got %d", attempts)
}
if resp.StatusCode != 200 {
t.Fatalf("status: %d", resp.StatusCode)
}
}Unit: CB открывается после серии отказов
func TestCircuitBreaker_OpensAfterThreshold(t *testing.T) {
cb := NewCircuitBreaker("test")
for i := 0; i < 25; i++ {
_, _ = cb.Execute(func() (*http.Response, error) {
return nil, errors.New("downstream fail")
})
}
_, err := cb.Execute(func() (*http.Response, error) {
t.Fatal("CB should not invoke fn when open")
return nil, nil
})
if !errors.Is(err, gobreaker.ErrOpenState) {
t.Fatalf("expected ErrOpenState, got %v", err)
}
}Integration: testcontainers + toxiproxy
Для проверки end-to-end поведения под сетевыми сбоями используй
toxiproxy (внутри compose): он сидит между сервисом и downstream и
по команде добавляет latency, drops packets, отдаёт reset. Запуск
тестов — в build-tag’е integration. Подробнее —
../conventions/testing.
Anti-patterns
Бесконечный retry
// ПЛОХО
for {
if err := call(); err == nil { break }
time.Sleep(time.Second)
}Без max_attempts / MaxElapsedTime / context — при долгом outage это
goroutine, висящая навсегда, занимающая DB-connection, держащая
request-context. Всегда есть cap сверху.
Retry без jitter
Thundering herd. См. §Retry. Используй RandomizationFactor не
меньше 0.1.
Retry non-retryable ошибки
// ПЛОХО
for i := 0; i < 3; i++ {
err := call()
if err == nil { return nil }
time.Sleep(100 * time.Millisecond)
}Если call() вернул 400 Bad Request — повтор даст те же 400, только с
задержкой в 300ms. Классифицируй через IsRetryable, оборачивай
non-retryable в backoff.Permanent.
Retry внутри транзакции
// ПЛОХО
db.InTx(ctx, func(tx pgx.Tx) error {
for i := 0; i < 3; i++ {
err := externalCall(ctx)
if err == nil { break }
time.Sleep(100 * time.Millisecond)
}
return insertRow(tx, ...)
})300ms retry внутри транзакции держит row-lock на 300ms → другие
запросы ждут → cascading slowdown. Retry снаружи InTx или внешний вызов
не внутри tx вообще (см. outbox).
POST retry без Idempotency-Key
// ПЛОХО
retryClient.Do(POST /v1/payments {amount: 100})Вдруг первый запрос прошёл, вторая попытка уронит второй платёж. Либо idempotency-key, либо POST не retry’ить.
Retry с одним и тем же таймаутом
// ПЛОХО
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
retryClient.Do(req.WithContext(ctx))Первая попытка проиграла по timeout за 100ms. Вторая попытка — тот же
ctx, уже отменённый. Retry никогда не попадёт в downstream. Либо
ctx без deadline и retry с MaxElapsedTime, либо timeout > sum of
all retries.
Свой рукописный CB поверх atomic counter
Выглядит просто. Через месяц обнаруживаешь, что он не учитывает
half-open, не скользит окном, считает failures всю жизнь процесса, не
имеет метрик. Используй gobreaker.
CB на DB-клиент
CB защищает от перегрузки downstream. БД перегрузить через pool
невозможно — pgxpool сам ставит запросы в очередь, а при исчерпании
возвращает ошибку. CB поверх pool’а — лишний уровень. Правильная защита
БД — размер пула + statement_timeout.
CB поверх in-memory или local-disk кода
Если «downstream» — твоя же goroutine / локальный файл / in-memory cache, CB бессмыслен. Он для сетевых вызовов.
FAQ
«Сколько retry на HTTP-клиенте — 3 или 5?» 3. Больше съедает time- budget user-facing endpoint’а. Для background worker’а — 5 (уже так настроено в Kafka consumer-middleware).
«Почему именно exponential, а не fixed interval?» Exponential снижает нагрузку на уже тормозящий downstream: первый retry быстрый (он может пройти), последующие всё реже. Fixed interval при массовых отказах держит постоянный rate, что не даёт downstream восстановиться.
«Нужен ли retry поверх outbox forwarder’а?» Нет. Forwarder сам
повторяет публикацию: если Kafka недоступен, строка остаётся unacked,
forwarder повторит на следующем тике (см.
outbox). Ручной retry добавит дубли.
«CB и retry одновременно — не слишком ли защит?» Нет, это штатная комбинация. Retry ловит редкие transient-сбои; CB ловит когда transient превращается в длительный outage. Они решают разные задачи.
«Что с context.DeadlineExceeded — это retryable?» Зависит от
того, чей deadline. Если ctx отменился снаружи (handler-timeout от
chi-middleware) — нет, retry не поможет. Если deadline был внутри
retry-client на одну попытку — да, следующая попытка может пройти в
свой slot.
«Почему не grpc retry policy?» Мы не используем gRPC для внутренних
вызовов — только HTTP (см.
../conventions/http-api). Если
когда-нибудь перейдём — тогда встроенный retry policy gRPC будет
первым кандидатом.
«Retry при net.ErrClosed — безопасно?» Да, это typically
clean TCP close. Но проверь, что запрос идемпотентный — отказ мог
произойти уже после server-side processing’а.
Связанные разделы
../conventions/events— retry в consumer middleware stack, PoisonQueue, DLQ.../conventions/http-api— timeouts, статус-коды, mapping ошибок.../conventions/observability—otelhttp.NewTransport, metrics, tracing retry-цепочки.../conventions/caching— stale cache как fallback при открытом CB.idempotent-consumer— дедупликация на consumer-стороне, обязательное условие retry.outbox— почему forwarder сам ретраит, не нужен внешний retry.api-composition— partial failure как fallback в list-view.../troubleshooting/kafka-consumer-stuck— когда retry превращается в rebalance loop.../how-to/add-metric-and-alert— alert’ы на CB state и retry exhausted.