Опирается на правила:
R-RES-ISO-1…R-RES-ISO-3иR-RES-ISO-X1…R-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.DefaultTransport— shared между всеми частями программы. Это всегда переопределяется явно.- Изоляция нужна, потому что зависание одной системы не должно блокировать другие: 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.Timeout—callTimeout: hard deadline на весь вызов (connect + request + response body). АналогOkHttp callTimeout(R-RES-TO-1).DialContext.Timeout—connectTimeout: TCP-соединение. Должно быть меньшеreadTimeout.ResponseHeaderTimeout—readTimeout: ожидание первого байта ответа. Накапливается после отправки запроса.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 и OdnaKassa | R-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-alive | R-RES-ISO-2 | MaxIdleConnsPerHost = maxConcurrent × 1.2 |
| Сумма pool'ов всех систем > пул БД / 2 | R-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.NewWeightedper-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.