
Интеграции — это нервная система продукта: платежи, логистика, KYC, геокодинг, рассылки. Когда сторонний сервис «тормозит» или падает, страдает воронка, выручка и репутация. Хорошая новость: большинство проблем можно смягчить простыми инженерными практиками — таймауты, аккуратные повторные попытки и circuit breaker. Это даёт:
Таймаут должен быть везде: у HTTP-клиента, в серверной обработке, в очередях, в БД-запросах, в внешнем SDK. Если где-то нет таймаута, там и будет утечка ресурсов при сбое: зависшие соединения, потоки, горутины.
Рекомендации:
Передавайте дедлайн по цепочке вызовов. Если пользователь закрыл страницу или истек срок операции — всё, что ниже по стеку, должно отмениться и освободить ресурсы. В Go это context.WithTimeout, в Python — asyncio.Timeout, в Java — CompletableFuture с таймаутами.
Выделяйте «переборки» по интеграциям: отдельные пулы соединений и очереди. Если у одного партнёра проблемы, они не должны съедать все потоки/соединения и ронять остальной функционал.
Повторяем только «временные» ошибки: сетевые таймауты, разрывы соединений, ответы 5xx. Не повторяем 4xx (кроме 409/429 по особым правилам), ошибки валидации и заведомо постоянные отказы.
Важно: повторная попытка может привести к повторной обработке на стороне партнёра. Если операция имеет побочные эффекты (списание, создание заказа), убедитесь, что она идемпотентна. Если партнёр это не гарантирует, защищайтесь у себя: храните ключ операции и статус, чтобы не отправить дубль.
Классика: увеличиваем паузу между попытками в геометрической прогрессии (например, 200мс, 400мс, 800мс…), добавляя случайный «дрожащий» компонент (джиттер). Это снижает пилообразную нагрузку на партнёра при восстановлении.
Когда внешний сервис в деградации, без ограничений мы будем бесконечно пытаться, выжигая потоки, CPU и порождая лавины ошибок. Circuit breaker — «автомат» на линии: если слишком много ошибок, «рубильник» открывается и быстрым отказом защищает систему. Через небольшие «зондирующие» запросы проверяем восстановление.
Порог обычно задают по доле ошибок и/или по числу подряд идущих неудач, плюс минимальное число запросов для статистики.
Минимальный набор метрик по каждой интеграции:
Алёрты стоит строить на отклонениях от SLO (например, 99% успешных запросов за 10 минут) и на длительном открытом состоянии breaker.
Протягивайте trace-id во все вызовы. Это даст быстрый ответ на вопрос «кто именно тормозит» и насколько долго. OpenTelemetry стало стандартом де-факто — оно поддерживает трейсинг, метрики и логи единым стеком.
Ниже — небольшой самодостаточный пример: HTTP-клиент с таймаутом, ретраями с экспоненциальной задержкой и джиттером, и простым circuit breaker на основе счёта подряд идущих неудач.
package main
import (
"context"
"errors"
"fmt"
"io"
"math"
"math/rand"
"net"
"net/http"
"strings"
"sync"
"time"
)
// SimpleBreaker — минималистичный circuit breaker на основе подряд идущих неудач.
type SimpleBreaker struct {
mu sync.Mutex
state string // "closed", "open", "half-open"
failCount int
threshold int
openUntil time.Time
openDuration time.Duration
halfOpenProbes int
probeBudget int
}
func NewSimpleBreaker(threshold int, openDuration time.Duration, halfOpenProbes int) *SimpleBreaker {
return &SimpleBreaker{
state: "closed",
threshold: threshold,
openDuration: openDuration,
halfOpenProbes: halfOpenProbes,
probeBudget: halfOpenProbes,
}
}
func (b *SimpleBreaker) allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
switch b.state {
case "closed":
return true
case "open":
if now.After(b.openUntil) {
b.state = "half-open"
b.probeBudget = b.halfOpenProbes
return true
}
return false
case "half-open":
if b.probeBudget > 0 {
b.probeBudget--
return true
}
return false
default:
return true
}
}
func (b *SimpleBreaker) onSuccess() {
b.mu.Lock()
defer b.mu.Unlock()
b.failCount = 0
if b.state != "closed" {
b.state = "closed"
}
}
func (b *SimpleBreaker) onFailure() {
b.mu.Lock()
defer b.mu.Unlock()
b.failCount++
if b.state == "half-open" || b.failCount >= b.threshold {
b.state = "open"
b.openUntil = time.Now().Add(b.openDuration)
}
}
// transientError определяет, стоит ли повторять попытку.
func transientError(err error, status int) bool {
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) {
return true // таймауты/временные сетевые
}
// разрыв соединения, EOF, reset и т.п.
if errors.Is(err, io.ErrUnexpectedEOF) || strings.Contains(err.Error(), "connection reset") {
return true
}
return false
}
// По кодам ответа
if status >= 500 && status != 501 && status != 505 { // 5xx кроме явных постоянных
return true
}
if status == 429 { // слишком много запросов — попробуем позже
return true
}
return false
}
// backoffWithJitter: экспоненциальная задержка с ограничением и джиттером.
func backoffWithJitter(base time.Duration, cap time.Duration, attempt int) time.Duration {
// base * 2^(attempt-1)
pow := time.Duration(math.Pow(2, float64(attempt-1)))
d := base * pow
if d > cap {
d = cap
}
// jitter в пределах 50% задержки
jitter := time.Duration(rand.Int63n(int64(d)/2 + 1))
return d + jitter
}
// callWithResilience — единая точка запроса с таймаутом, ретраями и breaker.
func callWithResilience(ctx context.Context, client *http.Client, breaker *SimpleBreaker, method, url string, body io.Reader, maxRetries int, baseBackoff, maxBackoff time.Duration) (int, []byte, error) {
if !breaker.allow() {
return 0, nil, fmt.Errorf("circuit breaker open")
}
var lastErr error
var lastStatus int
for attempt := 1; attempt <= maxRetries; attempt++ {
// Дедлайн на каждую попытку, чтобы не накапливался общий таймаут
perAttemptCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
req, _ := http.NewRequestWithContext(perAttemptCtx, method, url, body)
// Пример бизнес-идемпотентности: сквозной ключ операции
req.Header.Set("X-Idempotency-Key", "order-123-payment-1")
resp, err := client.Do(req)
var status int
var respBody []byte
if err == nil && resp != nil {
status = resp.StatusCode
rb, rerr := io.ReadAll(resp.Body)
resp.Body.Close()
if rerr == nil {
respBody = rb
} else {
err = rerr
}
}
if err == nil && status >= 200 && status < 300 {
breaker.onSuccess()
cancel()
return status, respBody, nil
}
// Решаем, повторять или нет
if !transientError(err, status) || attempt == maxRetries {
if err != nil {
lastErr = err
} else {
lastStatus = status
}
breaker.onFailure()
cancel()
break
}
// Транзитная ошибка — ждём и пробуем снова
backoff := backoffWithJitter(baseBackoff, maxBackoff, attempt)
cancel()
select {
case <-time.After(backoff):
// следующая попытка
case <-ctx.Done():
breaker.onFailure()
return 0, nil, ctx.Err()
}
}
if lastErr != nil {
return 0, nil, lastErr
}
return lastStatus, nil, fmt.Errorf("request failed with status %d", lastStatus)
}
func main() {
rand.Seed(time.Now().UnixNano())
// HTTP-клиент с ограничениями: общий таймаут сокета + лимит keep-alive
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 3 * time.Second,
}
client := &http.Client{
Transport: transport,
Timeout: 0, // используем per-attempt таймауты через контекст
}
breaker := NewSimpleBreaker(3, 10*time.Second, 2)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
status, body, err := callWithResilience(ctx, client, breaker, http.MethodGet, "https://httpbin.org/status/503", nil, 4, 200*time.Millisecond, 2*time.Second)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Status: %d Body: %s\n", status, string(body))
}
}
Как расширять:
Ниже — асинхронный вызов с таймаутом, ретраями с экспоненциальной задержкой и простым breaker. Без сторонних библиотек, только aiohttp.
import asyncio
import random
import time
from typing import Optional
import aiohttp
class SimpleBreaker:
def __init__(self, threshold: int = 3, open_seconds: float = 10.0, half_open_probes: int = 2):
self.state = 'closed'
self.fail_count = 0
self.threshold = threshold
self.open_until = 0.0
self.open_seconds = open_seconds
self.half_open_probes = half_open_probes
self.probe_budget = half_open_probes
def allow(self) -> bool:
now = time.time()
if self.state == 'closed':
return True
if self.state == 'open':
if now >= self.open_until:
self.state = 'half-open'
self.probe_budget = self.half_open_probes
return True
return False
if self.state == 'half-open':
if self.probe_budget > 0:
self.probe_budget -= 1
return True
return False
return True
def on_success(self):
self.fail_count = 0
self.state = 'closed'
def on_failure(self):
self.fail_count += 1
if self.state == 'half-open' or self.fail_count >= self.threshold:
self.state = 'open'
self.open_until = time.time() + self.open_seconds
def is_transient(status: Optional[int], exc: Optional[Exception]) -> bool:
if exc is not None:
return True # сетевые/таймауты — пробуем еще
if status is None:
return True
if 500 <= status < 600:
return True
if status == 429:
return True
return False
def backoff_with_jitter(base: float, cap: float, attempt: int) -> float:
delay = min(base * (2 ** (attempt - 1)), cap)
jitter = random.uniform(0, delay / 2)
return delay + jitter
async def call_with_resilience(session: aiohttp.ClientSession, breaker: SimpleBreaker, method: str, url: str,
max_retries: int = 4, per_attempt_timeout: float = 3.0,
base_backoff: float = 0.2, max_backoff: float = 2.0) -> tuple[int, bytes]:
if not breaker.allow():
raise RuntimeError('circuit breaker open')
last_status = None
last_exc = None
for attempt in range(1, max_retries + 1):
try:
timeout = aiohttp.ClientTimeout(total=per_attempt_timeout)
headers = {'X-Idempotency-Key': 'order-123-payment-1'}
async with session.request(method, url, timeout=timeout, headers=headers) as resp:
body = await resp.read()
if 200 <= resp.status < 300:
breaker.on_success()
return resp.status, body
if not is_transient(resp.status, None) or attempt == max_retries:
last_status = resp.status
breaker.on_failure()
break
except Exception as exc:
if not is_transient(None, exc) or attempt == max_retries:
last_exc = exc
breaker.on_failure()
break
# транзитная ошибка — подождем и повторим
await asyncio.sleep(backoff_with_jitter(base_backoff, max_backoff, attempt))
if last_exc is not None:
raise last_exc
if last_status is not None:
raise RuntimeError(f'failed with status {last_status}')
raise RuntimeError('request failed')
async def main():
connector = aiohttp.TCPConnector(limit=100, limit_per_host=10)
async with aiohttp.ClientSession(connector=connector) as session:
breaker = SimpleBreaker(threshold=3, open_seconds=10, half_open_probes=2)
try:
status, body = await call_with_resilience(session, breaker, 'GET', 'https://httpbin.org/status/503')
print('Status:', status, 'Body:', body[:200])
except Exception as e:
print('Error:', e)
if __name__ == '__main__':
asyncio.run(main())
Где доработать:
Типичные начальные значения:
Важно: подбирайте на основе SLO и фактических метрик. Один размер не подходит всем.
Правильно настроенные таймауты, ретраи и circuit breaker — недорогая страховка от чужих сбоев. Это не модные слова, а простые механизмы, которые экономят часы инженеров и спасают конверсию. Начните с базового набора: таймауты, 3–5 ретраев с джиттером, breaker на подряд идущие неудачи, метрики и трассировка. Дальше — только настройка порогов под ваши SLO и трафик.