Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Таймауты, ретраи и circuit breaker: устойчивые интеграции с партнёрами и меньше ночных инцидентов

Разработка и технологии17 марта 2026 г.
Большая доля инцидентов в продуктах связана не с нашим кодом, а со сбоями партнёрских сервисов. Правильные таймауты, аккуратные повторные попытки и circuit breaker позволяют переживать чужие падения без лавины ошибок. Разбираем принципы, готовые настройки и рабочие примеры кода на Go и Python.
Таймауты, ретраи и circuit breaker: устойчивые интеграции с партнёрами и меньше ночных инцидентов

Оглавление

  • Зачем это бизнесу
  • Базовые принципы надёжных запросов
    • Таймауты на всех уровнях
    • Отмена и дедлайны
    • Ограничение параллелизма (bulkhead)
  • Повторные попытки без побочных эффектов
    • Когда ретраить, когда нет
    • Экспоненциальная задержка и «джиттер»
    • Дедупликация на нашей стороне
  • Circuit breaker: как не тянуть падающего партнёра за собой
    • Состояния и пороги
    • Ошибки, по которым переключаемся
  • Наблюдаемость и SLO
    • Метрики и логи
    • Трассировка (distributed tracing)
  • Практика: минимальная реализация на Go
  • Практика: асинхронный пример на Python (aiohttp)
  • Конфигурация по средам
  • Чек-лист внедрения
  • Частые ошибки
  • Итоги

Зачем это бизнесу

Интеграции — это нервная система продукта: платежи, логистика, KYC, геокодинг, рассылки. Когда сторонний сервис «тормозит» или падает, страдает воронка, выручка и репутация. Хорошая новость: большинство проблем можно смягчить простыми инженерными практиками — таймауты, аккуратные повторные попытки и circuit breaker. Это даёт:

  • Меньше инцидентов и дежурств по ночам.
  • Стабильные SLA и прогнозируемые расходы.
  • Быструю локализацию проблем партнёров без каскадных отказов у нас.

Базовые принципы надёжных запросов

Таймауты на всех уровнях

Таймаут должен быть везде: у HTTP-клиента, в серверной обработке, в очередях, в БД-запросах, в внешнем SDK. Если где-то нет таймаута, там и будет утечка ресурсов при сбое: зависшие соединения, потоки, горутины.

Рекомендации:

  • Клиентский таймаут чуть меньше нашего бизнес-дедлайна на операцию.
  • На балансировщике и прокси — отдельные, чтобы отсекать «висящие» соединения.
  • На стороне сервера — таймаут на обработку запроса, чтобы не копить очередь.

Отмена и дедлайны

Передавайте дедлайн по цепочке вызовов. Если пользователь закрыл страницу или истек срок операции — всё, что ниже по стеку, должно отмениться и освободить ресурсы. В Go это context.WithTimeout, в Python — asyncio.Timeout, в Java — CompletableFuture с таймаутами.

Ограничение параллелизма (bulkhead)

Выделяйте «переборки» по интеграциям: отдельные пулы соединений и очереди. Если у одного партнёра проблемы, они не должны съедать все потоки/соединения и ронять остальной функционал.

Повторные попытки без побочных эффектов

Когда ретраить, когда нет

Повторяем только «временные» ошибки: сетевые таймауты, разрывы соединений, ответы 5xx. Не повторяем 4xx (кроме 409/429 по особым правилам), ошибки валидации и заведомо постоянные отказы.

Важно: повторная попытка может привести к повторной обработке на стороне партнёра. Если операция имеет побочные эффекты (списание, создание заказа), убедитесь, что она идемпотентна. Если партнёр это не гарантирует, защищайтесь у себя: храните ключ операции и статус, чтобы не отправить дубль.

Экспоненциальная задержка и «джиттер»

Классика: увеличиваем паузу между попытками в геометрической прогрессии (например, 200мс, 400мс, 800мс…), добавляя случайный «дрожащий» компонент (джиттер). Это снижает пилообразную нагрузку на партнёра при восстановлении.

Дедупликация на нашей стороне

  • Генерируйте ключ операции и связывайте его с бизнес-объектом.
  • При повторе проверяйте, не завершена ли операция ранее успешным результатом.
  • Будьте готовы безопасно обрабатывать повторные ответы от партнёра (например, «уже создано» — трактуем как успех).

Circuit breaker: как не тянуть падающего партнёра за собой

Когда внешний сервис в деградации, без ограничений мы будем бесконечно пытаться, выжигая потоки, CPU и порождая лавины ошибок. Circuit breaker — «автомат» на линии: если слишком много ошибок, «рубильник» открывается и быстрым отказом защищает систему. Через небольшие «зондирующие» запросы проверяем восстановление.

Состояния и пороги

  • Закрыт (Closed): работаем обычно. Счётчик ошибок крутится в скользящем окне.
  • Открыт (Open): все запросы мгновенно отклоняются заданное время (cooldown). Никаких попыток, экономим ресурсы.
  • Полуоткрыт (Half-Open): пускаем ограниченное число пробных запросов. Если они успешны — закрываем, если нет — снова открываем.

Порог обычно задают по доле ошибок и/или по числу подряд идущих неудач, плюс минимальное число запросов для статистики.

Ошибки, по которым переключаемся

  • Таймауты, обрывы, 5xx — учитываем как неудачи.
  • 4xx — чаще всего не учитываем (это ошибки входных данных, а не деградация сервиса). Исключения: 429 (слишком много запросов) — полезно включить в порог, чтобы быстро снизить частоту.

Наблюдаемость и SLO

Метрики и логи

Минимальный набор метрик по каждой интеграции:

  • Успехи/ошибки по кодам ответа (2xx/4xx/5xx/timeout/cancel).
  • Время ответа p50/p95/p99.
  • Состояние и переходы circuit breaker (открыт/закрыт/полуоткрыт).
  • Число ретраев и их исходы.

Алёрты стоит строить на отклонениях от SLO (например, 99% успешных запросов за 10 минут) и на длительном открытом состоянии breaker.

Трассировка (distributed tracing)

Протягивайте trace-id во все вызовы. Это даст быстрый ответ на вопрос «кто именно тормозит» и насколько долго. OpenTelemetry стало стандартом де-факто — оно поддерживает трейсинг, метрики и логи единым стеком.

Практика: минимальная реализация на Go

Ниже — небольшой самодостаточный пример: 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 в памяти каждого инстанса. Для кластера — можно синхронизировать через Redis, но аккуратно, чтобы не получить лишнюю связанность.

Практика: асинхронный пример на Python (aiohttp)

Ниже — асинхронный вызов с таймаутом, ретраями с экспоненциальной задержкой и простым 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())

Где доработать:

  • Ограничьте число одновременных запросов к конкретному партнёру (семафор или отдельный пул).
  • Добавьте учёт «веса» запросов: тяжёлые — в меньший отдельный пул.

Конфигурация по средам

  • Продукт: более строгие таймауты, агрессивнее открываем breaker, лимиты на пулы соединений.
  • Staging: чуть мягче, чтобы поймать редкие тайминги и тесты успевали.
  • Тесты: фиксируйте рандом (seed), чтобы сценарии «отказов» воспроизводились.

Типичные начальные значения:

  • Таймаут попытки: 2–5 секунд для HTTP, 200–800 мс для быстрых внутренних RPC.
  • Ретраи: 3–5 с экспоненциальным backoff и джиттером, максимум 2–8 секунд суммарно.
  • Breaker: порог 3–5 подряд неудач или 50–70% ошибок на окне из 20–50 запросов, открытие на 10–30 секунд, 1–3 пробных запроса в half-open.

Важно: подбирайте на основе SLO и фактических метрик. Один размер не подходит всем.

Чек-лист внедрения

  • Проставлены таймауты на каждом уровне: клиент, сервер, БД, очереди, SDK.
  • Везде прокидывается дедлайн/отмена.
  • Ретраи только для транзитных ошибок; есть backoff + джиттер.
  • Учтена идемпотентность бизнес-операций (ключ операции, защита от дублей у нас).
  • Настроены circuit breaker per-интеграция и лимиты параллелизма.
  • Метрики: успехи/ошибки, латентность, состояние breaker, число ретраев.
  • Трассировка с trace-id сквозь все вызовы, логи с кореляцией.
  • Алёрты по SLO и длительному открытому breaker.
  • Planned деградация: фолыбэки, кэш, «поставить в отложку» вместо синхронного отказа.

Частые ошибки

  • Бесконечные ретраи без таймаутов — гарантированная лавина и рост очередей.
  • Повторяем 4xx — плодим бесполезный трафик и скрываем баги.
  • Игнорируем джиттер — после восстановления партнёр ловит «стадный эффект».
  • Один общий пул соединений на все интеграции — проблемы одного роняют всех.
  • Нет наблюдаемости — «на глаз» не понять, что именно умерло.
  • Одинаковые настройки для всех интеграций — платежи, KYC и рассылки имеют разный профиль и цену ошибки.

Итоги

Правильно настроенные таймауты, ретраи и circuit breaker — недорогая страховка от чужих сбоев. Это не модные слова, а простые механизмы, которые экономят часы инженеров и спасают конверсию. Начните с базового набора: таймауты, 3–5 ретраев с джиттером, breaker на подряд идущие неудачи, метрики и трассировка. Дальше — только настройка порогов под ваши SLO и трафик.


таймаутыcircuit breakerнадёжность