Опирается на правила: R-RES-ISO-1R-RES-ISO-3 и R-RES-ISO-X1R-RES-ISO-X2раздел 2. Per-system isolation.

Важно знать

  • На каждую внешнюю систему — отдельный *http.Client с собственным *http.Transport, gobreaker.CircuitBreaker и semaphore.Weighted.
  • Имя системы одинаковое везде: gobreaker.Settings.Name, label в метриках Prometheus, префикс env-переменных конфига. Одно имя — одна единица изоляции.
  • Pool sizing: MaxIdleConnsPerHost ≈ maxConcurrent × 1.2; суммарно по всем системам — не более половины пула БД.
  • http.DefaultClient и &http.Client{} без явного Transport используют глобальный http.DefaultTransportshared между всеми частями программы. Это всегда переопределяется явно.
  • Изоляция нужна, потому что зависание одной системы не должно блокировать другие: shared transport исчерпывает MaxIdleConns, и остальные системы теряют idle-соединения.
  • Semaphore-bulkhead срабатывает раньше исчерпания пула и работает в текущей горутине — трейс-контекст и slog-атрибуты не теряются.
  • gobreaker не имеет встроенного slow-call порога: медленный вызов обрабатывается через context.WithTimeout — timeout-ошибка засчитывается в failure rate CB.

Если один *http.Client обслуживает вызовы к Sber и к фискализатору, то зависание Sber займёт все idle-соединения в shared http.DefaultTransport — и чеки встанут, хотя фискализатор работает. Изоляция по системам — первый структурный шаг при более чем одной внешней зависимости.

Отдельный *http.Client на каждую систему

R-RES-ISO-1: каждый out-adapter держит собственный *http.Client с явно сконфигурированным *http.Transport.

// adapters/out/sber/client.go

func newSberHTTPClient(cfg SberClientConfig) *http.Client {
    return &http.Client{
        Timeout: cfg.CallTimeout,
        Transport: &http.Transport{
            DialContext: (&net.Dialer{
                Timeout:   cfg.ConnectTimeout,
                KeepAlive: 30 * time.Second,
            }).DialContext,
            ResponseHeaderTimeout: cfg.ReadTimeout,
            MaxIdleConnsPerHost:   cfg.MaxConcurrent + 2,
            IdleConnTimeout:       90 * time.Second,
            TLSHandshakeTimeout:   5 * time.Second,
        },
    }
}

Аналогично для фискализатора — отдельная функция newReceiptHTTPClient в adapters/out/receipt/client.go. Никакого shared транспорта.

Что делает каждое поле:

  • http.Client.TimeoutcallTimeout: hard deadline на весь вызов (connect + request + response body). Аналог OkHttp callTimeout (R-RES-TO-1).
  • DialContext.TimeoutconnectTimeout: TCP-соединение. Должно быть меньше readTimeout.
  • ResponseHeaderTimeoutreadTimeout: ожидание первого байта ответа. Накапливается после отправки запроса.
  • MaxIdleConnsPerHost — размер idle-пула per-host. maxConcurrent + 2 — небольшой запас на keep-alive.
  • IdleConnTimeout — как долго держать idle TCP-соединение открытым. 90s — типовое значение.

Pool sizing (R-RES-ISO-2)

MaxIdleConnsPerHost задаёт ёмкость keep-alive пула. Нет смысла делать его сильно меньше maxConcurrent — тогда соединения придётся устанавливать заново при каждом всплеске нагрузки.

Формула: MaxIdleConnsPerHost = maxConcurrent × 1.2 (запас 20% покрывает idle-соединения, освобождённые bulkhead'ом, но ещё не вытесненные IdleConnTimeout).

Суммарный pool по всем системам ≤ пул БД / 2. Пример:

SBER_MAX_CONCURRENT=20   → MaxIdleConnsPerHost=22
RECEIPT_MAX_CONCURRENT=10 → MaxIdleConnsPerHost=12
INSURANCE_MAX_CONCURRENT=8 → MaxIdleConnsPerHost=10
─────────────────────────────────────────────────
сумма idle-конн ≈ 44      (при pool БД = 40: 44 > 20 — нарушение)

Решение: снизить maxConcurrent или увеличить пул БД (pgx MaxConns). Без коррекции HTTP-адаптеры начнут конкурировать с pgx за file-descriptor'ы в пике.

Структура адаптера

R-RES-ISO-3: имя системы (sber, receipt, insurance) одинаковое в gobreaker.Settings.Name, Prometheus-label и env-префиксе конфига.

// 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("sber"),
        sem:     semaphore.NewWeighted(int64(cfg.MaxConcurrent)),
        cfg:     cfg,
    }
}

newSberBreaker принимает имя системы явным параметром — то же имя пойдёт в метрику circuit_breaker_state{system="sber"} через OnStateChange callback.

Bulkhead semaphore.NewWeighted(maxConcurrent) срабатывает до того, как CB получит вызов. Семафор работает в той же горутине — context.Context с OTel-спаном и slog-атрибутами не теряется (в отличие от errgroup-пула).

Конфигурация per-system через env (R-RES-CFG-1, R-RES-ISO-3)

// adapters/out/sber/config.go

type SberClientConfig struct {
    BaseURL        string        `envconfig:"SBER_BASE_URL"         required:"true"`
    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"`
}

Имя-префикс SBER_ соответствует системному имени sber — по нему в мониторинге ищут алёрты и дашборды.

Аналогичная структура ReceiptClientConfig использует префикс RECEIPT_. Структуры собираются в единый OutboundConfig:

// adapters/out/config.go

type OutboundConfig struct {
    Sber      SberClientConfig
    Receipt   ReceiptClientConfig
    Insurance InsuranceClientConfig
}

envconfig.Process("", &cfg) читает все три блока за один вызов; дефолты из тегов — применяются без ConfigMap в локальной среде.

Полный вызов с изоляцией

Вызов Register для заказа Order: bulkhead → CB → HTTP:

// adapters/out/sber/sber_adapter.go

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 {
        bulkheadRejectedTotal.WithLabelValues("sber").Inc()
        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 {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        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
}

capTimeout уважает входящий дедлайн контекста (R-RES-TO-3):

func capTimeout(ctx context.Context, callTimeout time.Duration) (context.Context, context.CancelFunc) {
    deadline, ok := ctx.Deadline()
    if ok {
        remaining := time.Until(deadline) - 100*time.Millisecond
        if remaining > 0 && remaining < callTimeout {
            callTimeout = remaining
        }
    }
    return context.WithTimeout(ctx, callTimeout)
}

Функция переиспользуется во всех out-адаптерах — общий хелпер, не копируется per-system.

CB per-system

// adapters/out/sber/breaker.go

func newSberBreaker(name string) *gobreaker.CircuitBreaker {
    return gobreaker.NewCircuitBreaker(gobreaker.Settings{
        Name:        name,
        MaxRequests: 3,
        Interval:    0,
        Timeout:     30 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            if counts.Requests < 10 {
                return false
            }
            ratio := float64(counts.TotalFailures) / float64(counts.Requests)
            return ratio >= 0.30
        },
        IsSuccessful: func(err error) bool {
            return err == nil
        },
        OnStateChange: func(name string, from, to gobreaker.State) {
            cbState.WithLabelValues(name).Set(float64(to))
            slog.Warn("circuit breaker state changed",
                "system", name,
                "prev_state", from.String(),
                "new_state", to.String(),
            )
        },
    })
}

ReadyToTrip с порогом 0.30 — для платёжной системы: открывать быстро, не ждать деградации (R-RES-CB-3). Для нефинансовых систем типа фискализатора или страховки — 0.50.

Каждая система имеет собственный инстанс gobreaker.CircuitBreaker — их состояния не пересекаются. Sber в open не влияет на Receipt.

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

АнтипаттернПравилоЧто взамен
http.DefaultClient или &http.Client{} без Transport для внешних вызововR-RES-ISO-X2Явный *http.Transport с DialContext, ResponseHeaderTimeout, MaxIdleConnsPerHost
Один *http.Client для Sber и OdnaKassaR-RES-ISO-X1Отдельный *http.Client per-system
Один gobreaker.CircuitBreaker с Name: "default" на несколько системR-RES-ISO-X1, R-RES-CB-X3Отдельный gobreaker per-system с именем системы
MaxIdleConnsPerHost меньше maxConcurrent без запаса на keep-aliveR-RES-ISO-2MaxIdleConnsPerHost = maxConcurrent × 1.2
Сумма pool'ов всех систем > пул БД / 2R-RES-ISO-2Скорректировать MaxConcurrent или увеличить pgx MaxConns
Разные имена gobreaker.Settings.Name и Prometheus-label для одной системыR-RES-ISO-3Единое имя sber / receipt / insurance везде

Куда дальше

  • Timeouts — иерархия connectTimeout / readTimeout / callTimeout на *http.Transport и http.Client.
  • Circuit Breaker — gobreaker.Settings: count-based окно, ReadyToTrip, OnStateChange.
  • Bulkhead — semaphore.NewWeighted per-system, sizing и интеграция с CB.
  • Retry — retry.Do с RetryIf и BackOffDelay; границы in-memory vs task-queue.
  • Fallback — кеш-fallback через errors.As; запреты для money-операций.
  • Конфигурация — envconfig-теги, дефолты, OutboundConfig.
  • Health checks — TTL-кеш probe, SberHealthChecker, readiness vs liveness.
  • Observability — promauto-метрики CB/retry/bulkhead, OTel-атрибуты.
  • Async и polling — task-queue для долгого polling; time.Sleep только при total wait < 2s.
  • OpenAPI generator binding — oapi-codegen, размещение gobreaker/sem.Acquire на public-методе адаптера.
  • Где какая защита — outbound vs internal vs scheduler vs inbound.