Опирается на правила:
R-RES-CB-1…R-RES-CB-6иR-RES-CB-X1…R-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-2…R-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-1…R-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-X2 | gobreaker.NewCircuitBreaker |
Один CB с Name: "default" для Sber и OdnaKassa | R-RES-CB-X3 | Per-system name: "sber", "odnakassa" |
retry.Do без RetryIf — ретраить ErrOpenState | R-RES-RE-X4 | RetryIf исключает PaymentSystemUnavailableError |
MaxRequests: 1 в half-open | R-RES-CB-4 | 3 пробных вызова |
Хардкод 0.5 в ReadyToTrip для всех систем | R-RES-CFG-X1 | envconfig с 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_stategauge, 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 на адаптере, не на сгенерированном клиенте.