Опирается на правила: R-RES-WHERE-1R-RES-WHERE-4 и R-RES-WHERE-X1 из Resilience Style Guide → раздел 1. Где какая защита.

Важно знать

  • Outbound HTTP к внешним системам (платежи, фискализация, страхование, сторонние API) — полный набор: context.WithTimeout + gobreaker.CircuitBreaker + semaphore.Weighted + опционально retry.Do. Без CB первый «slow burn» внешней системы исчерпывает горутины.
  • Internal service-to-servicecontext.WithTimeout + gobreaker.CircuitBreaker. semaphore.Weighted — по тяжести трафика.
  • Schedulers и outbox-relay — durable retry через task-queue в БД (*_task), не retry.Do. In-memory retry живёт только в рамках одного вызова и не переживает рестарт сервиса.
  • Inbound REST (наш chi-хендлер) — rate limit на API Gateway. В коде — только при его отсутствии.
  • Локальный код (репозиторий, sqlc/pgx, in-memory вычисления) — без gobreaker/retry.Do. Сбои там реальные, не транзиентные.
  • В Go resilience строится на идиомах языка: context.Context — сквозной носитель таймаута; errors.As/errors.Is — точный фильтр для RetryIf; семафор работает в вызывающей горутине и не теряет трейс.
  • gobreaker и semaphore.Weightedper-system: один экземпляр на Sber, отдельный на ОднуКассу, отдельный на страхование.

gobreaker/retry.Do/semaphore — это не «навесить везде на всякий случай». У каждой группы вызовов своя категория защиты. Навесить не туда вреднее, чем не навесить: CB на репозитории мусорит метрики, retry.Do на write-методе без идемпотентности удваивает операцию.

Outbound HTTP к внешним системам — полный набор

R-RES-WHERE-1 — любой вызов к внешней системе защищается полным набором.

Структура адаптера: *http.Client с явным *http.Transport (таймауты), gobreaker.CircuitBreaker (fast-fail при деградации), semaphore.Weighted (ограничение параллелизма), опционально retry.Do на read-методах.

// adapters/out/sber/sber_adapter.go
type SberAdapter struct {
    client  *http.Client
    breaker *gobreaker.CircuitBreaker
    sem     *semaphore.Weighted
    cfg     SberClientConfig
}

func (a *SberAdapter) Register(ctx context.Context, order Order) (PaymentRef, error) {
    ctx, span := otel.Tracer("sber-adapter").Start(ctx, "SberAdapter.Register")
    defer span.End()
    span.SetAttributes(
        attribute.String("external.system", "sber"),
        attribute.String("circuit_breaker.state", a.breaker.State().String()),
    )

    if err := a.sem.Acquire(ctx, 1); err != nil {
        return PaymentRef{}, &PaymentSystemUnavailableError{System: "sber", Cause: err}
    }
    defer a.sem.Release(1)

    raw, err := a.breaker.Execute(func() (any, error) {
        callCtx, cancel := capTimeout(ctx, a.cfg.CallTimeout)
        defer cancel()
        return a.doRegister(callCtx, order)
    })
    if err != nil {
        if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
            return PaymentRef{}, &PaymentSystemUnavailableError{System: "sber", Cause: err}
        }
        return PaymentRef{}, fmt.Errorf("sber register: %w", err)
    }
    return raw.(PaymentRef), nil
}

Что даёт каждый слой:

  • context.WithTimeout + явный Transport — гарантия, что вызов не висит вечно. DialContext ограничивает TCP-handshake, ResponseHeaderTimeout — ожидание первого байта ответа, http.Client.Timeout — весь round-trip.
  • gobreaker.CircuitBreaker — fast-fail при деградации внешней системы. После 10 запросов с failure rate ≥ 50% CB открывается: следующие вызовы падают немедленно, не нагружая систему 30 секунд.
  • semaphore.Weighted — ограничение параллельных вызовов отдельно от connection pool. TCP-соединения ограничены MaxIdleConnsPerHost; семафор срабатывает чуть раньше и не даёт в очереди накопиться 50 горутинам, ожидающим коннект.
  • retry.Do — только на read-методах или write с Idempotency-Key. Register без ключа идемпотентности не ретраится.

Без CB первый медленный ответ от Сбера на 10s держит горутину. Двадцать параллельных запросов — 20 заблокированных горутин. Тридцать — исчерпан лимит пула, новые запросы ждут. С CB на 11-м вызове (после 10 failures) сервис падает быстро и управляемо, а не медленно и хаотично.

Internal service-to-service — timeout + CB

R-RES-WHERE-2 — вызовы между нашими микросервисами: обязательны context.WithTimeout и gobreaker.CircuitBreaker. semaphore.Weighted — если сервис тяжёлый или критичный.

// adapters/out/customer/customer_adapter.go
type CustomerServiceAdapter struct {
    client  *http.Client
    breaker *gobreaker.CircuitBreaker
}

func (a *CustomerServiceAdapter) GetCustomerProfile(ctx context.Context, id CustomerID) (CustomerProfile, error) {
    callCtx, cancel := capTimeout(ctx, 5*time.Second)
    defer cancel()

    raw, err := a.breaker.Execute(func() (any, error) {
        return a.doGetProfile(callCtx, id)
    })
    if err != nil {
        if errors.Is(err, gobreaker.ErrOpenState) {
            return CustomerProfile{}, &CustomerServiceUnavailableError{Cause: err}
        }
        return CustomerProfile{}, fmt.Errorf("get customer profile: %w", err)
    }
    return raw.(CustomerProfile), nil
}

Почему меньше, чем для внешних систем: внутренние сервисы под нашим контролем — SLA предсказуем, retry-семантика прозрачна. CB останавливает каскад при деградации; semaphore.Weighted нужен только когда вызов идёт из горячего пути с высоким параллелизмом (например, при обработке каждого события из Kafka).

Schedulers и outbox-relay — task-queue, не retry.Do

R-RES-WHERE-3 — для scheduled-работ и outbox-relay retry.Do не подходит.

Почему:

  • retry.Do живёт в памяти горутины. Рестарт pod'а — потеря попытки и счётчика.
  • retry.Do ретраит в рамках одного вызова: backoff растёт, но горутина занята. retry.Attempts(100) при retry.Delay(60s) = 100 минут заблокированной горутины.
  • gobreaker тоже in-memory: при рестарте CB сбрасывается в closed-state и не помнит, что внешняя система была недоступна.

Task-queue решает это через персистентную таблицу с next_attempt_at:

// scheduler/order_confirmation_scheduler.go
func (s *OrderConfirmationScheduler) Run(ctx context.Context) {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            s.processPending(ctx)
        }
    }
}

func (s *OrderConfirmationScheduler) processPending(ctx context.Context) {
    tasks, err := s.repo.FindDue(ctx, 50) // FOR UPDATE SKIP LOCKED
    if err != nil {
        slog.Error("find due tasks", "err", err)
        return
    }
    for _, t := range tasks {
        if err := s.sberAdapter.Confirm(ctx, t.OrderID); err != nil {
            s.repo.ScheduleRetry(ctx, t.ID, err.Error(), nextBackoff(t.RetryCount))
            continue
        }
        s.repo.MarkCompleted(ctx, t.ID)
    }
}

Структура таблицы (пример для подтверждения заказа):

CREATE TABLE order_confirmation_task (
    task_id         BIGSERIAL PRIMARY KEY,
    order_id        BIGINT       NOT NULL,
    status          TEXT         NOT NULL,  -- PENDING / IN_PROGRESS / COMPLETED / FAILED
    retry_count     INTEGER      NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ  NOT NULL,
    last_error      TEXT,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);
CREATE INDEX ix_oct_due ON order_confirmation_task (status, next_attempt_at)
    WHERE status IN ('PENDING', 'IN_PROGRESS');

Scheduler poll'ит по status = 'IN_PROGRESS' AND next_attempt_at <= now(). После N неудач — status = 'FAILED' и alert. Рестарт pod'а не теряет задачу: next_attempt_at остаётся в прошлом, следующий poll её подхватит.

Inbound REST — rate limit на edge

R-RES-WHERE-4 — защита нашего chi-хендлера от перегрузки клиентами — это rate limiter, и он живёт на API Gateway (Kong, Istio, Nginx), не в сервисе.

Почему на gateway:

  • Единая точка контроля для всех сервисов — не нужно добавлять middleware в каждый.
  • Защита до того, как запрос дошёл до Go-процесса: экономит CPU, connection slots, горутины.
  • Per-client лимиты по API-key / IP — gateway умеет из коробки.

Rate-limiting middleware в chi допустим только если gateway недоступен (внутренняя инсталляция без Gateway). В этом случае — golang.org/x/time/rate как http.Handler-обёртка до бизнес-хендлера.

Что запрещено

gobreaker вокруг репозитория и SQL

R-RES-WHERE-X1: никакого gobreaker.CircuitBreaker.Execute/retry.Do вокруг вызовов *pgxpool.Pool, sqlc-функций, in-memory вычислений.

// ПЛОХО — CB вокруг sqlc-запроса
func (r *OrderRepository) FindByID(ctx context.Context, id OrderID) (Order, error) {
    raw, err := r.breaker.Execute(func() (any, error) { // ← незачем
        return r.queries.GetOrder(ctx, int64(id))
    })
    // ...
}

Что не так:

  • Транзиентов нет. PostgreSQL либо доступен, либо нет. При реальном сбое pgxpool переподключается сам через MinConns/MaxConnIdleTime. CB здесь не помогает — он не знает о reconnect.
  • CB-state бесполезен. Open CB на репозитории означает «сервис полностью не работает». В этом случае лучше вернуть 500 и дать k8s перезапустить pod.
  • Метрики мусорят. circuit_breaker_state{system="orderRepo"} всегда либо closed (всё хорошо), либо open (всё плохо) — сигнал нулевой.

Корректно: pgxpool управляет соединениями, sqlc возвращает pgx.ErrNoRows / ошибки pgx, хендлер/usecase маппит их в 404/500. Локально нечего «защищать» от транзиентов.


АнтипаттернПравилоЧто взамен
gobreaker вокруг sqlc-запроса / pgxpoolR-RES-WHERE-X1Не нужно — pgxpool управляет reconnect
retry.Do вокруг метода репозиторияR-RES-WHERE-X1Не нужно
retry.Do вместо task-queue для scheduler (>30s отказ)R-RES-WHERE-3Task-queue с *_task-таблицей в БД
Rate-limiting middleware в каждом chi-хендлереR-RES-WHERE-4Centralized rate limit на API Gateway
Outbound без gobreaker и semaphore.WeightedR-RES-WHERE-1Полный набор обязателен
Один gobreaker с Name: "default" на Sber и ОднуКассуR-RES-ISO-3Отдельный gobreaker per-system

Куда дальше

  • Per-system isolation — отдельный *http.Client и gobreaker на каждую внешнюю систему.
  • Timeouts — иерархия connect / read / call в http.Transport.
  • Circuit Breaker — gobreaker.Settings: count-based окно, ReadyToTrip, OnStateChange.
  • Retry — retry.Do с RetryIf: когда повторять и когда нельзя.
  • Bulkhead — semaphore.NewWeighted отдельно от connection pool.
  • Fallback — errors.As для деградации; запрет нулевого Money.
  • Async и polling — task-queue и time.Sleep-граница в 2s.
  • Health checks — SberHealthChecker с TTL-кешем; probe без бизнес-вызова.
  • Observability — promauto, OTel-атрибуты, WARN на state-transition CB.
  • Configuration — envconfig-теги per-system, дефолты без хардкода.
  • OpenAPI generator binding — oapi-codegen, mapper generated DTO → domain.