Опирается на правила:
R-RES-WHERE-1…R-RES-WHERE-4иR-RES-WHERE-X1из Resilience Style Guide → раздел 1. Где какая защита.
Важно знать
- Outbound HTTP к внешним системам (платежи, фискализация, страхование, сторонние API) — полный набор:
context.WithTimeout+gobreaker.CircuitBreaker+semaphore.Weighted+ опциональноretry.Do. Без CB первый «slow burn» внешней системы исчерпывает горутины.- Internal service-to-service —
context.WithTimeout+gobreaker.CircuitBreaker.semaphore.Weighted— по тяжести трафика.- Schedulers и outbox-relay — durable retry через task-queue в БД (
*_task), неretry.Do. In-memory retry живёт только в рамках одного вызова и не переживает рестарт сервиса.- Inbound REST (наш chi-хендлер) — rate limit на API Gateway. В коде — только при его отсутствии.
- Локальный код (репозиторий, sqlc/pgx, in-memory вычисления) — без
gobreaker/retry.Do. Сбои там реальные, не транзиентные.- В Go resilience строится на идиомах языка:
context.Context— сквозной носитель таймаута;errors.As/errors.Is— точный фильтр дляRetryIf; семафор работает в вызывающей горутине и не теряет трейс.gobreakerиsemaphore.Weighted— per-system: один экземпляр на Sber, отдельный на ОднуКассу, отдельный на страхование.
gobreaker/retry.Do/semaphore — это не «навесить везде на всякий случай». У каждой группы вызовов своя категория защиты. Навесить не туда вреднее, чем не навесить: CB на репозитории мусорит метрики, retry.Do на write-методе без идемпотентности удваивает операцию.
Outbound HTTP к внешним системам — полный набор
R-RES-WHERE-1 — любой вызов к внешней системе защищается полным набором.
Структура адаптера: *http.Client с явным *http.Transport (таймауты), gobreaker.CircuitBreaker (fast-fail при деградации), semaphore.Weighted (ограничение параллелизма), опционально retry.Do на read-методах.
// 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) {
return PaymentRef{}, &PaymentSystemUnavailableError{System: "sber", Cause: err}
}
return PaymentRef{}, fmt.Errorf("sber register: %w", err)
}
return raw.(PaymentRef), nil
}
Что даёт каждый слой:
context.WithTimeout+ явныйTransport— гарантия, что вызов не висит вечно.DialContextограничивает TCP-handshake,ResponseHeaderTimeout— ожидание первого байта ответа,http.Client.Timeout— весь round-trip.gobreaker.CircuitBreaker— fast-fail при деградации внешней системы. После 10 запросов с failure rate ≥ 50% CB открывается: следующие вызовы падают немедленно, не нагружая систему 30 секунд.semaphore.Weighted— ограничение параллельных вызовов отдельно от connection pool. TCP-соединения ограниченыMaxIdleConnsPerHost; семафор срабатывает чуть раньше и не даёт в очереди накопиться 50 горутинам, ожидающим коннект.retry.Do— только на read-методах или write сIdempotency-Key.Registerбез ключа идемпотентности не ретраится.
Без CB первый медленный ответ от Сбера на 10s держит горутину. Двадцать параллельных запросов — 20 заблокированных горутин. Тридцать — исчерпан лимит пула, новые запросы ждут. С CB на 11-м вызове (после 10 failures) сервис падает быстро и управляемо, а не медленно и хаотично.
Internal service-to-service — timeout + CB
R-RES-WHERE-2 — вызовы между нашими микросервисами: обязательны context.WithTimeout и gobreaker.CircuitBreaker. semaphore.Weighted — если сервис тяжёлый или критичный.
// adapters/out/customer/customer_adapter.go
type CustomerServiceAdapter struct {
client *http.Client
breaker *gobreaker.CircuitBreaker
}
func (a *CustomerServiceAdapter) GetCustomerProfile(ctx context.Context, id CustomerID) (CustomerProfile, error) {
callCtx, cancel := capTimeout(ctx, 5*time.Second)
defer cancel()
raw, err := a.breaker.Execute(func() (any, error) {
return a.doGetProfile(callCtx, id)
})
if err != nil {
if errors.Is(err, gobreaker.ErrOpenState) {
return CustomerProfile{}, &CustomerServiceUnavailableError{Cause: err}
}
return CustomerProfile{}, fmt.Errorf("get customer profile: %w", err)
}
return raw.(CustomerProfile), nil
}
Почему меньше, чем для внешних систем: внутренние сервисы под нашим контролем — SLA предсказуем, retry-семантика прозрачна. CB останавливает каскад при деградации; semaphore.Weighted нужен только когда вызов идёт из горячего пути с высоким параллелизмом (например, при обработке каждого события из Kafka).
Schedulers и outbox-relay — task-queue, не retry.Do
R-RES-WHERE-3 — для scheduled-работ и outbox-relay retry.Do не подходит.
Почему:
retry.Doживёт в памяти горутины. Рестарт pod'а — потеря попытки и счётчика.retry.Doретраит в рамках одного вызова: backoff растёт, но горутина занята.retry.Attempts(100)приretry.Delay(60s)= 100 минут заблокированной горутины.gobreakerтоже in-memory: при рестарте CB сбрасывается в closed-state и не помнит, что внешняя система была недоступна.
Task-queue решает это через персистентную таблицу с next_attempt_at:
// scheduler/order_confirmation_scheduler.go
func (s *OrderConfirmationScheduler) Run(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.processPending(ctx)
}
}
}
func (s *OrderConfirmationScheduler) processPending(ctx context.Context) {
tasks, err := s.repo.FindDue(ctx, 50) // FOR UPDATE SKIP LOCKED
if err != nil {
slog.Error("find due tasks", "err", err)
return
}
for _, t := range tasks {
if err := s.sberAdapter.Confirm(ctx, t.OrderID); err != nil {
s.repo.ScheduleRetry(ctx, t.ID, err.Error(), nextBackoff(t.RetryCount))
continue
}
s.repo.MarkCompleted(ctx, t.ID)
}
}
Структура таблицы (пример для подтверждения заказа):
CREATE TABLE order_confirmation_task (
task_id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL,
status TEXT NOT NULL, -- PENDING / IN_PROGRESS / COMPLETED / FAILED
retry_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ix_oct_due ON order_confirmation_task (status, next_attempt_at)
WHERE status IN ('PENDING', 'IN_PROGRESS');
Scheduler poll'ит по status = 'IN_PROGRESS' AND next_attempt_at <= now(). После N неудач — status = 'FAILED' и alert. Рестарт pod'а не теряет задачу: next_attempt_at остаётся в прошлом, следующий poll её подхватит.
Inbound REST — rate limit на edge
R-RES-WHERE-4 — защита нашего chi-хендлера от перегрузки клиентами — это rate limiter, и он живёт на API Gateway (Kong, Istio, Nginx), не в сервисе.
Почему на gateway:
- Единая точка контроля для всех сервисов — не нужно добавлять middleware в каждый.
- Защита до того, как запрос дошёл до Go-процесса: экономит CPU, connection slots, горутины.
- Per-client лимиты по API-key / IP — gateway умеет из коробки.
Rate-limiting middleware в chi допустим только если gateway недоступен (внутренняя инсталляция без Gateway). В этом случае — golang.org/x/time/rate как http.Handler-обёртка до бизнес-хендлера.
Что запрещено
gobreaker вокруг репозитория и SQL
R-RES-WHERE-X1: никакого gobreaker.CircuitBreaker.Execute/retry.Do вокруг вызовов *pgxpool.Pool, sqlc-функций, in-memory вычислений.
// ПЛОХО — CB вокруг sqlc-запроса
func (r *OrderRepository) FindByID(ctx context.Context, id OrderID) (Order, error) {
raw, err := r.breaker.Execute(func() (any, error) { // ← незачем
return r.queries.GetOrder(ctx, int64(id))
})
// ...
}
Что не так:
- Транзиентов нет. PostgreSQL либо доступен, либо нет. При реальном сбое
pgxpoolпереподключается сам черезMinConns/MaxConnIdleTime. CB здесь не помогает — он не знает о reconnect. - CB-state бесполезен. Open CB на репозитории означает «сервис полностью не работает». В этом случае лучше вернуть 500 и дать k8s перезапустить pod.
- Метрики мусорят.
circuit_breaker_state{system="orderRepo"}всегда либо closed (всё хорошо), либо open (всё плохо) — сигнал нулевой.
Корректно: pgxpool управляет соединениями, sqlc возвращает pgx.ErrNoRows / ошибки pgx, хендлер/usecase маппит их в 404/500. Локально нечего «защищать» от транзиентов.
| Антипаттерн | Правило | Что взамен |
|---|---|---|
gobreaker вокруг sqlc-запроса / pgxpool | R-RES-WHERE-X1 | Не нужно — pgxpool управляет reconnect |
retry.Do вокруг метода репозитория | R-RES-WHERE-X1 | Не нужно |
retry.Do вместо task-queue для scheduler (>30s отказ) | R-RES-WHERE-3 | Task-queue с *_task-таблицей в БД |
| Rate-limiting middleware в каждом chi-хендлере | R-RES-WHERE-4 | Centralized rate limit на API Gateway |
Outbound без gobreaker и semaphore.Weighted | R-RES-WHERE-1 | Полный набор обязателен |
Один gobreaker с Name: "default" на Sber и ОднуКассу | R-RES-ISO-3 | Отдельный gobreaker per-system |
Куда дальше
- Per-system isolation — отдельный
*http.Clientиgobreakerна каждую внешнюю систему. - Timeouts — иерархия connect / read / call в
http.Transport. - Circuit Breaker —
gobreaker.Settings: count-based окно,ReadyToTrip,OnStateChange. - Retry —
retry.DoсRetryIf: когда повторять и когда нельзя. - Bulkhead —
semaphore.NewWeightedотдельно от connection pool. - Fallback —
errors.Asдля деградации; запрет нулевого Money. - Async и polling — task-queue и
time.Sleep-граница в 2s. - Health checks —
SberHealthCheckerс TTL-кешем; probe без бизнес-вызова. - Observability —
promauto, OTel-атрибуты, WARN на state-transition CB. - Configuration —
envconfig-теги per-system, дефолты без хардкода. - OpenAPI generator binding —
oapi-codegen, mapper generated DTO → domain.