
Любая интеграция — очереди, платёжные шлюзы, KYC, адреса доставки — иногда тормозит или ложится. Без защитных механизмов падает уже ваш продукт: растут задержки, размножаются ретраи, переполняются пулы соединений, истощаются ресурсы. Это выливается в:
Правильные таймауты, повторы с джиттером, Circuit Breaker и изоляция (Bulkhead) позволяют локализовать проблему, не давая ей тянуть весь продукт ко дну, и поддерживать работоспособность критичных сценариев. Это дешевле, чем «лить железо», и надёжнее, чем надеяться на удачу.
Корневая причина — несогласованность ожиданий. Ваш сервис ждёт ответ 10 секунд, внешний партнёр в пике отвечает 30–60 секунд. За это время у вас накапливаются висящие запросы, забиваются пулы, растёт очередь, а затем «всё упало». Добавьте жёсткие ретраи без паузы — и вы сами устраиваете DDoS партнёру и себе.
Каскадный отказ усиливают:
Таймаут — предельное время ожидания. Он должен быть короче вашего SLO на конечный пользовательский запрос. Если пользовательский SLO — 1 секунда «до первой полезной реакции», подчинённые вызовы не должны съедать весь бюджет.
Практика:
Ретраи — полезны при временных сбоях (сети, пик нагрузки). Но повторять нужно с экспоненциальной задержкой и случайным разбросом (джиттер), чтобы не «бить в такт» и не усугублять пик. Максимум 2–3 попытки для онлайновых запросов.
CB следит за ошибками и задержками вызовов к зависимости. Когда их доля превышает порог, «прерывает цепь»: дальнейшие звонки сходу отвергаются (fast‑fail) на время «охлаждения». Это даёт зависимой системе отдохнуть и экономит ваши ресурсы. Потом — «полуоткрытое» состояние: пробные вызовы проверяют, вернулась ли здоровая работа.
Ключевые настройки: окно наблюдения, порог ошибок, минимальное число запросов, длительность «охлаждения».
Идея из кораблестроения: разбить ресурсы на отсеки. У каждой интеграции — свой пул соединений, свой семафор или очередь. Если один партнёр «течёт», вода не затопит весь корабль.
Реализация: отдельные HTTP‑пулы, отдельные очереди задач, отдельные семафоры на поток для интеграций, лимиты параллелизма на конкретные операции.
Если зависимость недоступна, лучше отдать старые данные/уменьшенный функционал, чем «500». Примеры: показать закэшированные тарифы доставки, задержать бонусы до фоновой синхронизации, использовать статический список ПВЗ. Важно пометить пользователю, что данные могут обновиться позже.
Ниже — рабочий пример. Он показывает:
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"sync"
"time"
"github.com/sony/gobreaker"
)
// Настраиваем общий HTTP‑клиент с таймаутами на уровне транспорта
var httpClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
Timeout: 0, // общий Timeout отключаем; используем context с дедлайном на запрос
}
// Bulkhead: ограничим параллелизм вызовов к внешней системе до N
var bulkhead = make(chan struct{}, 20) // не более 20 одновременных вызовов
// Простой тёплый кэш последнего успешного ответа
type cache struct {
mu sync.RWMutex
value string
ts time.Time
}
func (c *cache) Get(ttl time.Duration) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.value == "" {
return "", false
}
if time.Since(c.ts) > ttl {
return "", false
}
return c.value, true
}
func (c *cache) Set(v string) {
c.mu.Lock()
c.value = v
c.ts = time.Now()
c.mu.Unlock()
}
var warmCache cache
// Настройки ретраев
const (
maxAttempts = 3
baseBackoff = 100 * time.Millisecond
maxBackoff = 2 * time.Second
cacheTTL = 30 * time.Second
callTimeout = 800 * time.Millisecond // дедлайн на один внешний вызов
cooldown = 5 * time.Second // время «охлаждения» для CB
minRequestsCB = 20 // минимальное число запросов для принятия решения
errorRatioThresh = 0.5 // порог доли ошибок
)
// Circuit Breaker с параметрами
var cb *gobreaker.CircuitBreaker
func init() {
rand.Seed(time.Now().UnixNano())
st := gobreaker.Settings{
Name: "external-api",
MaxRequests: 5, // в полуоткрытом состоянии допустим 5 пробных вызовов
Interval: 30 * time.Second, // окно сбора статистики
Timeout: cooldown, // сколько держать разомкнутым
ReadyToTrip: func(counts gobreaker.Counts) bool {
requests := float64(counts.Requests)
if requests < float64(minRequestsCB) {
return false
}
errors := float64(counts.TotalFailures)
errorRatio := errors / requests
return errorRatio >= errorRatioThresh
},
}
cb = gobreaker.NewCircuitBreaker(st)
}
func jitter(d time.Duration) time.Duration {
// равномерный джиттер +/- 50%
jd := time.Duration(rand.Int63n(int64(d))) - d/2
res := d + jd
if res < 0 {
return 0
}
if res > maxBackoff {
return maxBackoff
}
return res
}
func retryableStatus(code int) bool {
// Повторяем при 5xx и 429
if code >= 500 || code == 429 {
return true
}
return false
}
func doExternalCall(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if retryableStatus(resp.StatusCode) {
return "", fmt.Errorf("remote status %d", resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(b), nil
}
// Обёртка с ретраями, таймаутом, bulkhead и fallback из кэша
func callWithResilience(parent context.Context, url string) (string, error) {
// Bulkhead — берём слот
select {
case bulkhead <- struct{}{}:
defer func() { <-bulkhead }()
case <-parent.Done():
return "", parent.Err()
}
operation := func() (any, error) {
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
// Таймаут на попытку
ctx, cancel := context.WithTimeout(parent, callTimeout)
res, err := doExternalCall(ctx, url)
cancel()
if err == nil {
// Успех — обновляем тёплый кэш
warmCache.Set(res)
return res, nil
}
lastErr = err
// Если контекст закрыт — дальше смысла нет
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
// Немедленный выход — таймаут попытки
break
}
// Бэк‑офф с джиттером перед повтором, кроме последней попытки
if attempt < maxAttempts {
backoff := jitter(baseBackoff << (attempt - 1))
timer := time.NewTimer(backoff)
select {
case <-timer.C:
case <-parent.Done():
if !timer.Stop() {
<-timer.C
}
return nil, parent.Err()
}
}
}
return nil, lastErr
}
// Circuit Breaker
res, err := cb.Execute(operation)
if err != nil {
// Если цепь разомкнута или попытки не удались — пробуем тёплый кэш
if v, ok := warmCache.Get(cacheTTL); ok {
return v, nil
}
return "", err
}
return res.(string), nil
}
func main() {
url := "https://httpbin.org/delay/1" // внешняя зависимость; можно заменить на ваш URL
ctx := context.Background()
// Демонстрация периодических вызовов
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for i := 0; i < 10; i++ {
<-ticker.C
start := time.Now()
res, err := callWithResilience(ctx, url)
lat := time.Since(start)
if err != nil {
log.Printf("call failed: %v (latency=%s, cb_state=%s)", err, lat, cb.State().String())
continue
}
log.Printf("ok: %d bytes (latency=%s, cb_state=%s)", len(res), lat, cb.State().String())
}
log.Println("done")
}
Что важно в примере:
Какие метрики нужны:
Алерты — не по одной ошибке, а по устойчивым отклонениям: «>20% таймаутов 5 минут», «CB открыт > 2 минут», «p95 > X при QPS > Y». Так вы не будете «стрелять по воробьям».
Важно: договоритесь с партнёрами о лимитах RPS и окнах повторов, чтобы не усугублять пиковые ситуации.
Интеграции не обязаны быть идеальными, чтобы вы держали SLA. Достаточно дисциплины на своей стороне: таймауты, управляемые ретраи, Circuit Breaker, изоляция и предсказуемая деградация. Эти механики дешевле масштабирования «на авось», они сокращают простои, удерживают выручку и снимают стресс с команды поддержки. Начните с самых критичных зависимостей, включите метрики — и вы увидите эффект уже в ближайший релиз.