Опирается на правила: R-RES-CB-1R-RES-CB-6 и R-RES-CB-X1R-RES-CB-X3 из Resilience Style Guide → раздел 4. Circuit Breaker.

Важно знать

  • gobreaker.CircuitBreaker оборачивает public-метод структуры-адаптера. Не *http.Client, не handler, не репозиторий.
  • Count-based окно: MaxRequests: 3 (half-open), Interval: 0 (сброс при переходе в closed), минимум 10 вызовов до открытия.
  • ReadyToTrip: failure rate 50% для обычных систем, 30% для платежей.
  • Timeout: 30s — длина open-state. Затем half-open с 3 пробными вызовами: все успешны → closed, иначе → open снова.
  • gobreaker не имеет встроенного slow-call порога. Реализуется через context.WithTimeout — timeout-ошибка попадает в failure rate и учитывается CB.
  • При open-state CB возвращает gobreaker.ErrOpenState или gobreaker.ErrTooManyRequests. Адаптер маппит в port-specific ошибку, handler — в 503 / 409.
  • Не писать CB на sync.Mutex + счётчик вручную: нет sliding window, нет half-open, нет метрик.
  • OnStateChange — единственное место, где логируется переход. Не логировать каждый вызов.

Circuit Breaker — выключатель: когда внешняя система явно не отвечает, новые вызовы сразу возвращают ошибку, не занимают горутину на время timeout. Это защищает goroutine-пул от вымывания «висящими» запросами и даёт системе шанс восстановиться без дополнительной нагрузки. Раскрытие раздела 4 гайда на стеке github.com/sony/gobreaker.

gobreaker.CircuitBreaker на public-методе адаптера

R-RES-CB-1: CB оборачивает public-метод структуры, реализующей port из core/. Не встроен в *http.Client, не навешен на handler, не трогает репозиторий.

// 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) {
            span.SetStatus(codes.Error, "circuit open")
            return PaymentRef{}, &PaymentSystemUnavailableError{System: "sber", Cause: err}
        }
        span.RecordError(err)
        return PaymentRef{}, fmt.Errorf("sber register: %w", err)
    }
    return raw.(PaymentRef), nil
}

Почему именно так:

  • Не на *http.Client — клиент знает только про TCP и HTTP. CB — это бизнес-решение «прекратить вызовы», его место в адаптере.
  • Не в хелпере executeCall(backendName string) — имя CB становится строкой, compile-time проверки нет; опечатка → runtime-паника или неизвестный инстанс.
  • Не на handler'е ConfirmOrderHandler — handler оркеструет логику, port — граница изоляции.
  • Не на репозитории — локальные SQL-операции не имеют транзиентного поведения; ошибка там — реальная ошибка среды, не «иногда работает, иногда нет».

Настройки CB — count-based окно

R-RES-CB-2R-RES-CB-5: конструкция gobreaker.Settings.

// adapters/out/sber/sber_adapter.go

func newSberBreaker(name string) *gobreaker.CircuitBreaker {
    return gobreaker.NewCircuitBreaker(gobreaker.Settings{
        Name:        name,            // "sber" — R-RES-ISO-3
        MaxRequests: 3,               // half-open: столько пробных вызовов (R-RES-CB-4)
        Interval:    0,               // count-based: сбрасывать счётчик при переходе closed
        Timeout:     30 * time.Second,// длина open-state (R-RES-CB-4)
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            if counts.Requests < 10 {   // min calls до принятия решения (R-RES-CB-2)
                return false
            }
            ratio := float64(counts.TotalFailures) / float64(counts.Requests)
            return ratio >= 0.30        // 30% для платёжной системы (R-RES-CB-3)
        },
        IsSuccessful: func(err error) bool {
            if err == nil {
                return true
            }
            var netErr net.Error
            if errors.As(err, &netErr) && netErr.Timeout() {
                return false            // timeout-ошибки — failures (аналог slow-call, R-RES-CB-5)
            }
            if isRetriable5xx(err) {
                return false
            }
            return true
        },
        OnStateChange: func(name string, from, to gobreaker.State) {
            slog.Warn("circuit breaker state changed",
                "system", name,
                "prev_state", from.String(),
                "new_state", to.String(),
            )
            updateCBStateMetric(name, to)          // R-RES-OBS-1
        },
    })
}

Почему count-based, а не time-based:

  • Outbound из OrderService идёт неравномерно: всплеск вызовов после создания заказа, пауза до подтверждения. Time-based окно (например, за 30 секунд) в паузу не накапливает данных и принимает решение по 1–2 вызовам, что ненадёжно.
  • Count-based критерий предсказуем: «30% из 50 последних вызовов» — понятный инвариант, не зависящий от частоты трафика.

Slow-call через IsSuccessful

R-RES-CB-5: gobreaker не имеет встроенного slow-call порога в отличие от Resilience4j. Медленные, но ещё не упавшие вызовы нужно помечать вручную через IsSuccessful.

// adapters/out/sber/sber_adapter.go

func (a *SberAdapter) doRegister(ctx context.Context, order Order) (PaymentRef, error) {
    start := time.Now()
    ref, err := a.httpRegister(ctx, order)
    elapsed := time.Since(start)
    if elapsed > a.cfg.SlowCallThreshold && err == nil {
        // вызов технически успешен, но медленный — сигнализируем CB через sentinel
        return ref, &slowCallError{elapsed: elapsed}
    }
    return ref, err
}

IsSuccessful в Settings помечает *slowCallError как неуспешный, CB учитывает его в failure rate. Это позволяет открыть CB ещё до того, как медленные вызовы начнут давать реальные timeout-ошибки.

Half-open: 3 пробных вызова

R-RES-CB-4: после Timeout: 30s CB переходит в half-open и пропускает MaxRequests: 3 вызовов.

  • Все 3 успешны → closed, нормальная работа.
  • Хотя бы 1 не успешен → open, ещё 30s ожидания.

Почему 3, а не 1 и не 10:

  • 1 — слишком хрупко: одна транзиентная ошибка во время восстановления снова закрывает систему на 30s. Если внешняя система восстанавливалась нестабильно, CB никогда не закроется.
  • 10 — лишняя нагрузка в момент восстановления. Ещё слабая система получает 10 одновременных запросов и может снова упасть.
  • 3 — достаточно для статистики и не создаёт пика нагрузки.

Маппинг ErrOpenState в port-specific ошибку

R-RES-CB-6: gobreaker возвращает gobreaker.ErrOpenState (CB открыт) и gobreaker.ErrTooManyRequests (превышен MaxRequests в half-open). Адаптер маппит оба в port-specific ошибку; handler — в HTTP-статус.

// core/port/payment_port.go

type PaymentSystemUnavailableError struct {
    System string
    Cause  error
}

func (e *PaymentSystemUnavailableError) Error() string {
    return fmt.Sprintf("payment system %s unavailable: %v", e.System, e.Cause)
}
// adapters/in/http/order_handler.go

func (h *OrderHandler) Confirm(w http.ResponseWriter, r *http.Request) {
    ref, err := h.confirmUseCase.Execute(r.Context(), cmd)
    if err != nil {
        var unavail *PaymentSystemUnavailableError
        if errors.As(err, &unavail) {
            writeJSON(w, http.StatusServiceUnavailable, ProblemDetails{
                Code:    "payment_system_unavailable",
                Message: "Платёжная система временно недоступна, повторите позже",
            })
            return
        }
        writeJSON(w, http.StatusInternalServerError, internalError(err))
        return
    }
    writeJSON(w, http.StatusOK, toResponse(ref))
}

Пробрасывать gobreaker.ErrOpenState через слои наружу нельзя: handler не должен знать про gobreaker — это деталь адаптера, не контракт port'а.

Конфигурация через envconfig

R-RES-CFG-1 / R-RES-CFG-3: все параметры CB через env-переменные, не хардкодом; имя системы везде одно.

// adapters/out/sber/config.go

type SberClientConfig struct {
    ConnectTimeout    time.Duration `envconfig:"SBER_CONNECT_TIMEOUT"     default:"2s"`
    ReadTimeout       time.Duration `envconfig:"SBER_READ_TIMEOUT"        default:"10s"`
    CallTimeout       time.Duration `envconfig:"SBER_CALL_TIMEOUT"        default:"15s"`
    SlowCallThreshold time.Duration `envconfig:"SBER_SLOW_CALL_THRESHOLD" default:"5s"`
    MaxConcurrent     int           `envconfig:"SBER_MAX_CONCURRENT"      default:"20"`
    CBMinRequests     uint32        `envconfig:"SBER_CB_MIN_REQUESTS"     default:"10"`
    CBFailureRate     float64       `envconfig:"SBER_CB_FAILURE_RATE"     default:"0.30"`
    CBOpenTimeout     time.Duration `envconfig:"SBER_CB_OPEN_TIMEOUT"     default:"30s"`
    BaseURL           string        `envconfig:"SBER_BASE_URL"            required:"true"`
}

gobreaker.Settings.Name = "sber" = префикс SBER_ = метка system="sber" в Prometheus. Если нужно переключиться с 30% на 50% для тестовой среды, достаточно установить SBER_CB_FAILURE_RATE=0.50 — redeploy не нужен.

Observability: метрики и трейс

R-RES-OBS-1R-RES-OBS-3: метрики через promauto, OTel-span на каждом adapter-методе.

// adapters/out/metrics.go

var (
    cbStateGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{
        Name: "circuit_breaker_state",
        Help: "Current state: 0=closed, 1=open, 2=half-open",
    }, []string{"system"})
)

func updateCBStateMetric(system string, state gobreaker.State) {
    var val float64
    switch state {
    case gobreaker.StateOpen:
        val = 1
    case gobreaker.StateHalfOpen:
        val = 2
    }
    cbStateGauge.WithLabelValues(system).Set(val)
}

Вызывается из OnStateChange — не на каждом запросе, а только при переходах. Лог при переходе — единственный структурный WARN; успешные вызовы не логируются.

Инициализация адаптера

// adapters/out/sber/sber_adapter.go

func NewSberAdapter(cfg SberClientConfig) *SberAdapter {
    return &SberAdapter{
        client:  newSberHTTPClient(cfg),
        breaker: newSberBreaker("sber"),
        sem:     semaphore.NewWeighted(int64(cfg.MaxConcurrent)),
        cfg:     cfg,
    }
}

Порядок вызовов в Register — важен: сначала sem.Acquire (bulkhead), затем breaker.Execute (CB), внутри — capTimeout + HTTP. Семафор срабатывает раньше CB, не давая накопить очередь в half-open.

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

АнтипаттернПравилоЧто взамен
gobreaker на репозитории или SQL-запросеR-RES-CB-X1Только на public-методе out-adapter
Самописный CB на sync.Mutex + счётчикR-RES-CB-X2gobreaker.NewCircuitBreaker
Один CB с Name: "default" для Sber и OdnaKassaR-RES-CB-X3Per-system name: "sber", "odnakassa"
retry.Do без RetryIf — ретраить ErrOpenStateR-RES-RE-X4RetryIf исключает PaymentSystemUnavailableError
MaxRequests: 1 в half-openR-RES-CB-43 пробных вызова
Хардкод 0.5 в ReadyToTrip для всех системR-RES-CFG-X1envconfig с per-system prefix
gobreaker.ErrOpenState пробрасывать через слоиR-RES-CB-6Маппинг в PaymentSystemUnavailableError в адаптере

Куда дальше

  • Per-system isolation — отдельный *http.Client и CB-инстанс на каждую систему.
  • Timeouts — capTimeout, иерархия connect/read/call и slow-call порог.
  • Bulkhead — semaphore.NewWeighted в паре с CB.
  • Retry — когда retry.Do допустим и как согласовать RetryIf с CB.
  • Fallback — что возвращать при ErrOpenState для нон-критичных запросов.
  • Health checks — TTL-кешированный probe и readiness через CB-состояние.
  • Observability — circuit_breaker_state gauge, OTel-span атрибуты, WARN на transition.
  • Конфигурация — envconfig-теги, per-system prefix, дефолты.
  • Где какая защита — outbound vs internal vs scheduler vs inbound.
  • Async и polling — когда task-queue вместо in-memory retry.
  • OpenAPI generator binding — CB на адаптере, не на сгенерированном клиенте.