Опирается на правила: R-RES-CFG-1R-RES-CFG-3 и R-RES-CFG-X1 из Resilience Style Guide → раздел 8. Конфигурация.

Важно знать

  • Параметры CB, retry, bulkhead и timeout — через envconfig-теги (default:, required:), не числами прямо в коде.
  • Это позволяет менять их через env-переменные / Kubernetes ConfigMap без пересборки.
  • Defaults задаются тегом default: в struct-полях; per-system — через префикс SBER_, RECEIPT_, INSURANCE_.
  • Имя системы в gobreaker.Settings.Name = ENV-префикс = метрика: sber, receipt, insurance, odnakassa.
  • Структура одинакова для каждой системы: *ClientConfig с таймаутами, MaxConcurrent, BaseURL.
  • Хардкод чисел в gobreaker.NewCircuitBreaker(...) — скрытая конфигурация; она не управляется через ConfigMap/env.

Параметры resilience — это operational knobs: их подкручивают по реальному поведению в проде. Если они зашиты в Go-коде, любое изменение требует пересборки и выкатки. Если вынесены в env — SRE меняет ConfigMap и перезапускает pod за 30 секунд. Раскрытие раздела 8 гайда для Go-стека.

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

R-RES-CFG-1: параметры — вне кода, через внешний конфиг.

В Go-стеке стандарт — пакет github.com/kelseyhightower/envconfig. Каждая система получает свою struct с тегами:

// 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"`
    MaxConcurrent  int           `envconfig:"SBER_MAX_CONCURRENT"  default:"20"`
    BaseURL        string        `envconfig:"SBER_BASE_URL"        required:"true"`

    CBWindowSize    int           `envconfig:"SBER_CB_WINDOW_SIZE"     default:"50"`
    CBMinRequests   int           `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"`
    CBMaxRequests   int           `envconfig:"SBER_CB_MAX_REQUESTS"    default:"3"`

    RetryAttempts   int           `envconfig:"SBER_RETRY_ATTEMPTS"     default:"3"`
    RetryDelay      time.Duration `envconfig:"SBER_RETRY_DELAY"        default:"200ms"`
}
// adapters/out/receipt/config.go
type ReceiptClientConfig struct {
    ConnectTimeout time.Duration `envconfig:"RECEIPT_CONNECT_TIMEOUT" default:"2s"`
    ReadTimeout    time.Duration `envconfig:"RECEIPT_READ_TIMEOUT"    default:"8s"`
    CallTimeout    time.Duration `envconfig:"RECEIPT_CALL_TIMEOUT"    default:"12s"`
    MaxConcurrent  int           `envconfig:"RECEIPT_MAX_CONCURRENT"  default:"15"`
    BaseURL        string        `envconfig:"RECEIPT_BASE_URL"        required:"true"`

    CBWindowSize  int     `envconfig:"RECEIPT_CB_WINDOW_SIZE"  default:"50"`
    CBMinRequests int     `envconfig:"RECEIPT_CB_MIN_REQUESTS" default:"10"`
    CBFailureRate float64 `envconfig:"RECEIPT_CB_FAILURE_RATE" default:"0.50"`
    CBOpenTimeout time.Duration `envconfig:"RECEIPT_CB_OPEN_TIMEOUT" default:"30s"`
}

Чтение при старте:

// adapters/out/config.go
type OutboundConfig struct {
    Sber      SberClientConfig
    Receipt   ReceiptClientConfig
    Insurance InsuranceClientConfig
}

func LoadOutboundConfig() (OutboundConfig, error) {
    var cfg OutboundConfig
    if err := envconfig.Process("", &cfg.Sber); err != nil {
        return OutboundConfig{}, fmt.Errorf("sber config: %w", err)
    }
    if err := envconfig.Process("", &cfg.Receipt); err != nil {
        return OutboundConfig{}, fmt.Errorf("receipt config: %w", err)
    }
    if err := envconfig.Process("", &cfg.Insurance); err != nil {
        return OutboundConfig{}, fmt.Errorf("insurance config: %w", err)
    }
    return cfg, nil
}

Kubernetes ConfigMap с этими переменными меняется без пересборки. SRE в инцидент снижает SBER_CB_FAILURE_RATE=0.20 и перекатывает под.

Defaults vs per-system

R-RES-CFG-2: общие типовые значения идут через теги default:, per-system отличия — в соответствующей struct.

Если у большинства систем порог failure rate 0.50, а у платёжной 0.30 — это видно сразу при чтении struct:

// receipt — стандартный порог
CBFailureRate float64 `envconfig:"RECEIPT_CB_FAILURE_RATE" default:"0.50"`

// sber — критичная система, порог ниже
CBFailureRate float64 `envconfig:"SBER_CB_FAILURE_RATE" default:"0.30"`

Это отклонение от стандарта немедленно заметно при code review. Если кто-то добавляет новую систему Insurance и забывает обосновать 0.20 — вопрос возникнет сам.

В Kubernetes deployment.yaml env-переменные группируются по префиксу системы, не вперемежку:

env:
  # sber
  - name: SBER_BASE_URL
    valueFrom:
      secretKeyRef:
        name: sber-credentials
        key: base-url
  - name: SBER_CALL_TIMEOUT
    value: "15s"
  - name: SBER_CB_FAILURE_RATE
    value: "0.30"
  # receipt
  - name: RECEIPT_BASE_URL
    valueFrom:
      secretKeyRef:
        name: receipt-credentials
        key: base-url
  - name: RECEIPT_CALL_TIMEOUT
    value: "12s"

При появлении новой системы OdnaKassa — добавляется struct OdnaKassaClientConfig с тегами ODNAKASSA_* и defaults, и новая группа env-переменных в ConfigMap.

Имена систем — единая ось

R-RES-CFG-3: имя системы в gobreaker.Settings.Name = ENV-префикс = метрика. Это одна и та же строка, везде.

// adapters/out/sber/sber_adapter.go
func newSberBreaker(cfg SberClientConfig) *gobreaker.CircuitBreaker {
    return gobreaker.NewCircuitBreaker(gobreaker.Settings{
        Name:        "sber",               // ← имя для логов, метрик
        MaxRequests: uint32(cfg.CBMaxRequests),
        Timeout:     cfg.CBOpenTimeout,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            if counts.Requests < uint32(cfg.CBMinRequests) {
                return false
            }
            ratio := float64(counts.TotalFailures) / float64(counts.Requests)
            return ratio >= cfg.CBFailureRate
        },
        OnStateChange: func(name string, from, to gobreaker.State) {
            slog.Warn("circuit breaker state changed",
                "system", name,
                "prev_state", from.String(),
                "new_state", to.String(),
            )
            cbStateGauge.WithLabelValues(name).Set(float64(to))
        },
    })
}

Prometheus-метрика circuit_breaker_state{system="sber"} содержит то же "sber", что и ENV-префикс SBER_ и поле slog. SRE, глядя в Grafana на system="sber", сразу знает, какой ConfigMap-ключ менять.

Имена — короткие, строчные, без окружения и хостов: sber, odnakassa, insurance, receipt. Не sber-payment-prod-eu-west-1.

Конфигурация и сборка адаптера

Конфиг передаётся в конструктор адаптера, внутри которого собирается весь resilience-стек:

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

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

Параметры не дублируются: конфиг читается один раз при старте, передаётся в конструктор, используется при инициализации http.Transport, gobreaker.Settings и semaphore.Weighted. Если SBER_MAX_CONCURRENT=25http.Transport.MaxIdleConnsPerHost и semaphore.NewWeighted(25) получат одно значение.

Для CustomerAdapter (внутренний s2s) набор меньше — timeout + CB, без bulkhead и retry (по R-RES-WHERE-2):

// adapters/out/customer/config.go
type CustomerClientConfig struct {
    ConnectTimeout time.Duration `envconfig:"CUSTOMER_CONNECT_TIMEOUT" default:"1s"`
    ReadTimeout    time.Duration `envconfig:"CUSTOMER_READ_TIMEOUT"    default:"5s"`
    CallTimeout    time.Duration `envconfig:"CUSTOMER_CALL_TIMEOUT"    default:"8s"`
    BaseURL        string        `envconfig:"CUSTOMER_BASE_URL"        required:"true"`

    CBFailureRate float64       `envconfig:"CUSTOMER_CB_FAILURE_RATE" default:"0.50"`
    CBOpenTimeout time.Duration `envconfig:"CUSTOMER_CB_OPEN_TIMEOUT" default:"30s"`
}

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

АнтипаттернПравилоЧто взамен
gobreaker.NewCircuitBreaker(gobreaker.Settings{Timeout: 30 * time.Second}) числами в кодеR-RES-CFG-X1Поле конфига CBOpenTimeout с тегом default:"30s"
Один SberClientConfig с полями на несколько систем (SberCallTimeout, ReceiptCallTimeout)R-RES-CFG-2Отдельная struct per-system
gobreaker.Settings{Name: "payment"} при ENV-префиксе SBER_R-RES-CFG-3Name: "sber" — единое имя везде
Разные имена CB и bulkhead одной системы ("sber-cb" и "sber-bh")R-RES-CFG-3"sber" везде
envconfig.Process("", &cfg) без проверки ошибкиR-RES-CFG-1Проверять и возвращать ошибку при старте
MaxConcurrent: 20 в конструкторе без конфигаR-RES-CFG-X1semaphore.NewWeighted(int64(cfg.MaxConcurrent))

Куда дальше

  • Изоляция по системам — почему имя в gobreaker.Settings.Name совпадает с ENV-префиксом и метрикой.
  • Circuit Breaker — параметры ReadyToTrip, Timeout, MaxRequests, slow-call через контекстный timeout.
  • Bulkhead — sizing semaphore.NewWeighted относительно MaxIdleConnsPerHost.
  • Retry — retry.Attempts, retry.BackOffDelay, RetryIf — без 4xx и gobreaker.ErrOpenState.
  • Timeouts — иерархия connectTimeout < readTimeout < callTimeout в http.Transport.
  • Observability — метрики promauto и OTel-атрибуты при tuning по метрикам.
  • Health Checks — TTL-кеш probe и readiness по всем системам.
  • Async и polling — task-queue вместо time.Sleep-цикла при долгом retry.
  • Fallback — деградация без money-операций и без тихого «успеха».
  • Где какая защита — полный набор по типу вызова (external / internal / scheduler).