Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

Таймауты, повторы и Circuit Breaker: как остановить зависания и удержать SLA без лишних серверов

Разработка и технологии12 февраля 2026 г.
Зависшие запросы и цепные сбои в интеграциях съедают SLA и бюджет. Разложим по полочкам, как настроить таймауты, управляемые повторы и Circuit Breaker, чтобы деградация была контролируемой, инцидентов стало меньше, а инфраструктуру не пришлось раздувать.
Таймауты, повторы и Circuit Breaker: как остановить зависания и удержать SLA без лишних серверов

  • Оглавление
    • Зачем это бизнесу
    • Что ломается без таймаутов
    • Таймауты: базовый контракт
      • Практика выбора таймаутов
      • Go: правильные таймауты в http-клиенте и gRPC
    • Повторы (retry): когда можно и как безопасно
      • Алгоритм с экспоненциальной паузой и джиттером
      • Код: повторы в Go с джиттером
      • Код: повторы в Node.js без зависимостей
    • Circuit Breaker: защита от каскадных падений
      • Ключевые настройки и метрики
      • Код: Circuit Breaker на Go (gobreaker)
    • Комбинируем: порядок слоёв и граничные условия
    • Распространение дедлайна по цепочке
      • Go: middleware для X-Request-Deadline
    • Наблюдаемость: что мерить и как алертить
      • Go: метрики Prometheus
      • Правила алертов в Prometheus
    • Чек-лист внедрения
    • Бизнес-эффект и цифры
    • Антипаттерны и частые ошибки
    • План внедрения за неделю

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

Когда один сервис ждёт ответа от другого «сколько потребуется», очередь запросов быстро забивает пул соединений, каскадом срываются тайм-ауты, а клиенты видят пиковую задержку и ошибки. Правильные таймауты, управляемые повторы (retry) и Circuit Breaker переводят хаос в предсказуемое поведение: мы или успеваем в оговорённый срок, или быстро и контролируемо деградируем (например, отвечаем кэшем или «позже попробуем»). В итоге SLA/аптайм ровнее, инцидентов меньше, а серверов — не больше, чем нужно.

Что ломается без таймаутов

  • Зависшие соединения съедают пул — новые запросы ждут в очереди и тоже валятся.
  • Повторы от клиентов (браузеров, SDK, шлюзов) множатся и превращаются в «бурю повторов».
  • Средняя задержка нормальная, но хвост p95/p99 разрастается — клиенты страдают.
  • Интеграции с внешними поставщиками «тянут вниз» весь ваш сервис при их деградации.

Таймауты: базовый контракт

Таймаут — это дедлайн для операции. Его надо задавать в каждом сетевом вызове и для всей цепочки целиком.

Виды таймаутов:

  • На установление соединения (connect).
  • На ожидание первого байта/ответа (read/write).
  • Общий дедлайн на операцию (total), передаваемый дальше по цепочке.

Практика выбора таймаутов

  • Опираться на p99 задержки между конкретными сервисами, а не на «пальцем в небо».
  • Делить общий бюджет времени запроса между слоями: фронт → API → сервис → база/провайдер.
  • Выставлять общий дедлайн чуть меньше клиентского (например, клиент 2.5с → сервер 2.3с), чтобы успеть вернуть управляемую ошибку.
  • Выключать повторы для неидемпотентных операций или сопровождать их идемпотентными ключами.

Go: правильные таймауты в http-клиенте и gRPC

package main

import (
	"context"
	"crypto/rand"
	"encoding/binary"
	"fmt"
	"net"
	"net/http"
	"time"
)

func httpClient() *http.Client {
	// Тонкие настройки: отдельные таймауты на этапы соединения
	transport := &http.Transport{
		DialContext: (&net.Dialer{
			Timeout:   300 * time.Millisecond, // connect timeout
			KeepAlive: 30 * time.Second,
		}).DialContext,
		TLSHandshakeTimeout:   500 * time.Millisecond,
		ResponseHeaderTimeout: 700 * time.Millisecond, // ждать заголовка ответа
		ExpectContinueTimeout: 100 * time.Millisecond,
	}
	return &http.Client{
		Transport: transport,
		Timeout:   1200 * time.Millisecond, // общий таймаут клиента как последний рубеж
	}
}

// deadlineContext создаёт контекст с дедлайном относительно "сейчас".
func deadlineContext(parent context.Context, d time.Duration) (context.Context, context.CancelFunc) {
	return context.WithTimeout(parent, d)
}

func main() {
	client := httpClient()
	ctx, cancel := deadlineContext(context.Background(), 2*time.Second)
	defer cancel()

	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com/api", nil)
	// Пробрасываем дедлайн дальше по цепочке (в миллисекундах epoch)
	deadline, _ := ctx.Deadline()
	b := make([]byte, 8)
	binary.BigEndian.PutUint64(b, uint64(deadline.UnixMilli()))
	req.Header.Set("X-Request-Deadline", fmt.Sprintf("%d", binary.BigEndian.Uint64(b)))

	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("ошибка запроса:", err)
		return
	}
	defer resp.Body.Close()
	fmt.Println("статус:", resp.Status)
}

Примечание по gRPC: обязательно задавайте context.WithTimeout/WithDeadline на каждый вызов, а на клиенте — параметры keepalive и per-RPC timeout.

Повторы (retry): когда можно и как безопасно

Повторы полезны при временных сбоях (всплески задержек, случайные таймауты, редкие 502/503). Но они опасны, если:

  • Операция неидемпотентна (списание денег, создание заказа без идемпотентного ключа).
  • Повтор не ограничен по времени и количеству.
  • Повторы синхронные, одновременно у многих клиентов — появляется «буря повторов».

Правила:

  • Повторяем только идемпотентные операции или операции с идемпотентным ключом.
  • Ограничиваем общий бюджет времени повторов (например, не более 30% от дедлайна запроса).
  • Используем экспоненциальную паузу с джиттером (случайной рассинхронизацией), чтобы клиенты не били в один такт.

Алгоритм с экспоненциальной паузой и джиттером

  • Начальная пауза: base (например, 50–100 мс).
  • На каждом шаге backoff *= 2 до maxBackoff.
  • Добавляем джиттер: умножаем на случайный коэффициент в диапазоне [0.5, 1.5].
  • Останавливаемся по: успеху, достижению maxRetries, превышению дедлайна/бюджета.

Код: повторы в Go с джиттером

package retry

import (
	"context"
	"crypto/rand"
	"encoding/binary"
	"errors"
	"math"
	"net/http"
	"time"
)

// Jitter в диапазоне [0.5, 1.5]
func jitter() float64 {
	var b [8]byte
	_, _ = rand.Read(b[:])
	u := binary.LittleEndian.Uint64(b[:])
	return 0.5 + float64(u%1000)/1000.0 // 0.5..1.499
}

type ShouldRetry func(resp *http.Response, err error) bool

type Options struct {
	MaxRetries int
	BaseDelay  time.Duration
	MaxDelay   time.Duration
	// Доля общего дедлайна, которую готовы отдать на повторы (0..1)
	RetryBudget float64
}

func Do(ctx context.Context, client *http.Client, req *http.Request, should ShouldRetry, opt Options) (*http.Response, error) {
	if opt.BaseDelay <= 0 {
		opt.BaseDelay = 50 * time.Millisecond
	}
	if opt.MaxDelay <= 0 {
		opt.MaxDelay = 800 * time.Millisecond
	}
	if opt.MaxRetries < 0 {
		opt.MaxRetries = 0
	}
	if opt.RetryBudget <= 0 || opt.RetryBudget > 1 {
		opt.RetryBudget = 0.3
	}

	var start time.Time
	if deadline, ok := ctx.Deadline(); ok {
		start = time.Now()
		// ограничим бюджет повторов
		budget := time.Until(deadline)
		budget = time.Duration(float64(budget) * opt.RetryBudget)
		ctx2, cancel := context.WithTimeout(ctx, budget)
		defer cancel()
		ctx = ctx2
	}

	backoff := opt.BaseDelay
	attempt := 0
	for {
		attempt++
		// Клонируем запрос с новым контекстом на каждый повтор
		cloned := req.Clone(ctx)
		resp, err := client.Do(cloned)
		if !should(resp, err) {
			return resp, err
		}
		if attempt > opt.MaxRetries {
			if err == nil {
				return resp, nil
			}
			return nil, err
		}
		// Ждем с экспоненциальным ростом и джиттером
		wait := time.Duration(float64(backoff) * jitter())
		if wait > opt.MaxDelay {
			wait = opt.MaxDelay
		}
		select {
		case <-time.After(wait):
			// ok
		case <-ctx.Done():
			if err == nil {
				return nil, ctx.Err()
			}
			return nil, errors.Join(err, ctx.Err())
		}
		// Увеличиваем паузу
		backoff = time.Duration(math.Min(float64(backoff)*2, float64(opt.MaxDelay)))
	}
}

// Пример should-функции
func transient(resp *http.Response, err error) bool {
	if err != nil {
		// таймауты, сброс соединения — попробуем ещё
		return true
	}
	if resp.StatusCode >= 500 && resp.StatusCode < 600 {
		return true
	}
	if resp.StatusCode == http.StatusTooManyRequests { // 429
		return true
	}
	return false
}

Код: повторы в Node.js без зависимостей

// node >= 18 (встроенный fetch)
import { setTimeout as delay } from 'node:timers/promises'

function jitter() {
  return 0.5 + Math.random() // 0.5..1.5
}

export async function fetchWithRetry(url, options = {}) {
  const {
    maxRetries = 3,
    baseDelayMs = 80,
    maxDelayMs = 800,
    retryBudgetMs,
    shouldRetry = (res, err) => {
      if (err) return true
      const sc = res.status
      return sc >= 500 || sc === 429
    },
    signal,
  } = options

  const controller = new AbortController()
  const signals = [controller.signal]
  if (signal) {
    signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true })
  }

  const start = Date.now()
  let attempt = 0
  let backoff = baseDelayMs

  while (true) {
    attempt += 1
    try {
      const res = await fetch(url, { ...options, signal: controller.signal })
      if (!shouldRetry(res, null) || attempt > maxRetries) return res
    } catch (err) {
      if (!shouldRetry(null, err) || attempt > maxRetries) throw err
    }

    if (retryBudgetMs && Date.now() - start + backoff > retryBudgetMs) {
      // бюджет исчерпан — выходим
      if (options.throwOnBudgetExceeded) throw new Error('retry budget exceeded')
      return fetch(url, { ...options, signal: controller.signal })
    }

    const wait = Math.min(Math.floor(backoff * jitter()), maxDelayMs)
    await delay(wait, undefined, { signal: controller.signal })
    backoff = Math.min(backoff * 2, maxDelayMs)
  }
}

Circuit Breaker: защита от каскадных падений

Circuit Breaker (далее — «брейкер») следит за ошибками и задержками вызовов к зависимости. Если доля неуспехов превысила порог, брейкер «открывается» и сразу отклоняет новые запросы без похода к проблемной зависимости. Через короткое время он переходит в «полуоткрытое» состояние, пробует немного трафика и либо закрывается (всё ок), либо снова открывается.

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

Ключевые настройки и метрики

  • Размер скользящего окна (по времени или числу запросов), минимальный объём трафика для оценки.
  • Порог ошибки: например, >50% неуспехов за последние N секунд.
  • Какие ошибки считать: таймауты, 5xx, 429; 4xx обычно не считаем, это ошибки запроса.
  • Время открытого состояния (cooldown) и частота проб в полуоткрытом состоянии.
  • Метрики: состояние брейкера, доля ошибок, время ответа при открытом брейкере, число отказов.

Код: Circuit Breaker на Go (gobreaker)

# установка зависимости
go get github.com/sony/gobreaker
package breaker

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"time"

	"github.com/sony/gobreaker"
)

type Client struct {
	bc     *gobreaker.CircuitBreaker
	httpCl *http.Client
}

func New() *Client {
	st := gobreaker.Settings{
		Name:        "provider-api",
		MaxRequests: 5,                       // в полуоткрытом состоянии
		Interval:    30 * time.Second,        // окно для сброса счётчиков
		Timeout:     5 * time.Second,         // время открытого состояния
		ReadyToTrip: func(counts gobreaker.Counts) bool {
			// открываемся, если больше 50% неудач и >20 запросов в окне
			total := counts.Requests
			fail := counts.TotalFailures()
			return total >= 20 && float64(fail)/float64(total) > 0.5
		},
	}
	bc := gobreaker.NewCircuitBreaker(st)
	return &Client{bc: bc, httpCl: &http.Client{Timeout: 1500 * time.Millisecond}}
}

func (c *Client) Get(ctx context.Context, url string) (*http.Response, error) {
	res, err := c.bc.Execute(func() (interface{}, error) {
		req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
		resp, err := c.httpCl.Do(req)
		if err != nil {
			return nil, err
		}
		if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
			return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
		}
		return resp, nil
	})
	if err != nil {
		// быстрый фолбэк при открытом брейкере
		if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
			return nil, fmt.Errorf("fast-fail: %w", err)
		}
		return nil, err
	}
	return res.(*http.Response), nil
}

Комбинируем: порядок слоёв и граничные условия

  • Сначала задаём таймаут и дедлайн на весь запрос.
  • Внутри — повторы с бюджетом времени, экспоненциальной паузой и джиттером.
  • Оборачиваем вызов брейкером, чтобы при деградации зависимости быстро отказывать и не тратить бюджет впустую.
  • На уровне API возвращаем чёткие коды: 503 для временной недоступности, 504 для превышения дедлайна.

Важно: не вкладывайте внутрь брейкера собственные повторы зависимости, иначе умножите нагрузку. Повторы управляем в одном месте, ближе к клиенту.

Распространение дедлайна по цепочке

Чтобы каждый следующий сервис знал, сколько времени осталось, передавайте дедлайн в заголовке, например X-Request-Deadline как метку времени в миллисекундах. На входе проверяйте, сколько осталось, и урезайте свои таймауты, чтобы успеть вернуть ответ, а не оборваться посередине.

Go: middleware для X-Request-Deadline

package deadline

import (
	"context"
	"net/http"
	"strconv"
	"time"
)

const HeaderDeadline = "X-Request-Deadline"

func WithDeadline(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if v := r.Header.Get(HeaderDeadline); v != "" {
			if ms, err := strconv.ParseInt(v, 10, 64); err == nil {
				dl := time.UnixMilli(ms)
				if time.Until(dl) <= 0 {
					http.Error(w, "deadline exceeded", http.StatusGatewayTimeout)
					return
				}
				ctx, cancel := context.WithDeadline(r.Context(), dl)
				defer cancel()
				r = r.WithContext(ctx)
			}
		}
		next.ServeHTTP(w, r)
	})
}

Наблюдаемость: что мерить и как алертить

Главные метрики:

  • Время ответа (гистограммы p50/p95/p99) по каждой зависимости.
  • Доля ошибок (error rate) по классам: таймауты, 5xx, 429.
  • Количество повторов и их исход: сколько спасли, сколько сдались.
  • Состояние брейкера (open/half-open/closed), частота fast-fail.
  • Занятость пулов соединений/воркеров.

Go: метрики Prometheus

package metrics

import (
	"net/http"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
	reqLatency = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "http_client_request_duration_seconds",
			Help:    "Время ответа внешних запросов",
			Buckets: prometheus.DefBuckets, // 0.005..10с
		},
		[]string{"dep", "code"},
	)
	retriesTotal = prometheus.NewCounterVec(
		prometheus.CounterOpts{Name: "http_client_retries_total", Help: "Число повторов"},
		[]string{"dep", "result"}, // result: success|giveup
	)
	breakerState = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{Name: "circuit_breaker_state", Help: "Состояние брейкера: 0 closed, 1 open, 0.5 half-open"},
		[]string{"dep"},
	)
)

func init() {
	prometheus.MustRegister(reqLatency, retriesTotal, breakerState)
}

func Handler() http.Handler { return promhttp.Handler() }

Правила алертов в Prometheus

groups:
  - name: sla-reliability
    rules:
      - alert: HighErrorRateUpstream
        expr: sum by(dep) (rate(http_client_requests_errors_total[5m])) / sum by(dep) (rate(http_client_requests_total[5m])) > 0.1
        for: 10m
        labels:
          severity: page
        annotations:
          summary: "Высокая доля ошибок к зависимости {{ $labels.dep }}"

      - alert: BreakerOpenTooLong
        expr: circuit_breaker_state{dep!=""} == 1
        for: 5m
        labels:
          severity: page
        annotations:
          summary: "Брейкер открыт более 5 минут: {{ $labels.dep }}"

      - alert: LatencyP99Spiked
        expr: histogram_quantile(0.99, sum by (le, dep) (rate(http_client_request_duration_seconds_bucket[5m]))) > 1.5
        for: 10m
        labels:
          severity: warn
        annotations:
          summary: "Всплеск p99 к {{ $labels.dep }}"

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

  • Для каждого исходящего вызова проверьте и задайте: connect/read/total таймауты.
  • Определите, какие операции можно повторять. Для остальных — идемпотентные ключи или запрет повторов.
  • Включите повторы с бюджетом времени и джиттером. Ограничьте maxRetries.
  • Оберните вызовы к «хрупким» зависимостям брейкером. Настройте окно и порог по фактическим метрикам.
  • Пробрасывайте дедлайн по цепочке (заголовок), уважайте его на каждом слое.
  • Добавьте метрики и алерты. Проведите нагрузочный тест с искусственной деградацией зависимости.

Бизнес-эффект и цифры

  • Сокращение p99 на 20–40% за счёт быстрого отказа вместо ожидания «в никуда».
  • Снижение каскадных сбоев: один падающий провайдер не валит весь API.
  • Экономия на инфраструктуре: меньше раздувать пулы и поднимать лишние реплики «на всякий».
  • Предсказуемый SLA: деградация контролируемая, ошибки «быстрые» и понятные пользователю.

Антипаттерны и частые ошибки

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

План внедрения за неделю

  • День 1: Аудит исходящих вызовов. Включить таймауты на все клиенты (HTTP, gRPC, база, брокеры).
  • День 2: Разметить операции по идемпотентности. Добавить идемпотентные ключи там, где возможно.
  • День 3: Включить повторы с бюджетом времени и джиттером. Метрики повторов.
  • День 4: Поставить брейкеры на «топ‑3» хрупких зависимости по метрикам. Настроить окна/пороги.
  • День 5: Проброс дедлайна по цепочке. Мидлварь на входе и в исходящих клиентах.
  • День 6: Нагрузочный тест с искусственными 5xx и задержками. Проверка алертов и фолбэков.
  • День 7: Подчистить настройки по результатам, обновить документацию для команды.

Итог: набор простых инженерных практик превращает случайные провалы в предсказуемое поведение. Это напрямую влияет на деньги — чем стабильнее и быстрее вы отвечаете, тем меньше отток, больше конверсий и ниже счёт за железо.


таймаутыcircuit breakerretry