Retry и Circuit Breaker¶
Deep-dive по паттернам обработки временных отказов при вызовах внешних
зависимостей: HTTP-клиенты к другим сервисам, Redis, Kafka, S3, БД. Reference
по middleware-стеку consumer'а — ../conventions/events.md.
Эта страница отвечает на вопрос: как правильно повторять запросы, когда
отступать, и как защитить сервис от каскадного падения downstream.
Содержание¶
- Когда это нужно
- Когда НЕ нужно
- Retry: exponential backoff + jitter
- Что можно 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.md). - 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¶
Стандартная формула интервала между попытками:
Значения по умолчанию для 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.md
уже задаёт MaxRetries=5, InitialInterval=500ms, MaxInterval=30s. Там
нет клиента с таймером — можно позволить себе более долгие попытки.
Jitter обязателен. Без него два клиента, получившие отказ в одну миллисекунду, повторят запрос одновременно — и одновременно же получат следующий отказ. С jitter ±25% они разъедутся во времени и не нагрузят downstream синхронно.
Используем github.com/cenkalti/backoff/v4 для клиентской логики — это
стандарт в экосистеме Go, та же библиотека под капотом у Watermill-retry.
Что можно 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, если ошибку/ответ имеет смысл повторить.
func IsRetryable(resp *http.Response, err error) bool {
if errors.Is(err, context.Canceled) {
return false
}
if err != nil {
// сетевые ошибки чаще всего transient
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
}
}
Эта функция — единый источник правды. Не дублируй её в каждом клиенте.
Идемпотентность — обязательное условие¶
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.md.
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.md).
В 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
}
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.md:
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.mdпро порядок middleware. - Задержка блокирует партицию. Пока handler retry'ит, другие
сообщения той же партиции не обрабатываются. Если
InitialInterval * 2^MaxRetries > max.poll.interval.ms→ rebalance → см.../troubleshooting/kafka-consumer-stuck.md.
Правило: если handler стабильно падает > 5 раз подряд на одном и том же сообщении — это не transient-ошибка. PoisonQueue отправит в DLQ, алерт сработает, разбирайся руками.
Circuit Breaker¶
Circuit breaker — state machine с тремя состояниями:
stateDiagram-v2
[*] --> Closed
Closed --> Open: failure_ratio > threshold в окне
Open --> HalfOpen: прошло cooldown
HalfOpen --> Closed: probe-запрос успешен
HalfOpen --> Open: probe-запрос упал
- 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.md). Отмечаем ответ заголовком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.md. -
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
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.md.
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.md.
Anti-patterns¶
Бесконечный retry¶
Без 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.md).
POST retry без Idempotency-Key¶
Вдруг первый запрос прошёл, вторая попытка уронит второй платёж. Либо 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.md). Ручной 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.md). Если
когда-нибудь перейдём — тогда встроенный retry policy gRPC будет
первым кандидатом.
«Retry при net.ErrClosed — безопасно?» Да, это typically
clean TCP close. Но проверь, что запрос идемпотентный — отказ мог
произойти уже после server-side processing'а.
Связанные разделы¶
../conventions/events.md— retry в consumer middleware stack, PoisonQueue, DLQ.../conventions/http-api.md— timeouts, статус-коды, mapping ошибок.../conventions/observability.md—otelhttp.NewTransport, metrics, tracing retry-цепочки.../conventions/caching.md— stale cache как fallback при открытом CB.idempotent-consumer.md— дедупликация на consumer-стороне, обязательное условие retry.outbox.md— почему forwarder сам ретраит, не нужен внешний retry.api-composition.md— partial failure как fallback в list-view.../troubleshooting/kafka-consumer-stuck.md— когда retry превращается в rebalance loop.../how-to/add-metric-and-alert.md— alert'ы на CB state и retry exhausted.