Перейти к содержанию

Retry и Circuit Breaker

Deep-dive по паттернам обработки временных отказов при вызовах внешних зависимостей: HTTP-клиенты к другим сервисам, Redis, Kafka, S3, БД. Reference по middleware-стеку consumer'а — ../conventions/events.md. Эта страница отвечает на вопрос: как правильно повторять запросы, когда отступать, и как защитить сервис от каскадного падения downstream.

Содержание

Когда это нужно

Любой сетевой вызов может упасть без причины со стороны кода: 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-redis retry на уровне отдельной команды по умолчанию включён (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.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.Canceledbackoff.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 исчерпаны?

Вариантов три, в порядке предпочтения:

  1. 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
  1. Partial response. Для list-view (api composition) — отдаём данные тех сервисов, которые ответили; поле от упавшего сервиса = null/"unavailable". См. api-composition.md.

  2. 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

// ПЛОХО
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.md).

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.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'а.

Связанные разделы