
Даже идеально написанные системы периодически дают кратковременные сбои: пиковая нагрузка, короткие сетевые провалы, внедрение новой версии рядом, холодный старт. В такие моменты запросы падают с таймаутами или кодами 429/503/504. Если просто «пожать плечами», мы теряем конверсию и портим SLA. Если бездумно долбить повтором «каждую секунду по десять раз» — кладём соседний сервис и платим за лишнюю инфраструктуру.
Правильно настроенные повторы (экспоненциальная пауза + случайный разброс) дают баланс:
Повторы допустимы, когда операция безопасна или повторяемая. Простое правило:
Подсказки:
Почему экспонента? Пауза растёт после каждой неудачи: 50 мс, 100 мс, 200 мс, 400 мс… Так мы не «давим» сервис, который и так в беде, и всё же даём себе шанс дождаться восстановления.
Почему случайный разброс (джиттер)? Если тысячи клиентов синхронно проснулись ровно через 200 мс — они снова создадут пик. Случайная компонента разносит их во времени.
Варианты джиттера:
Ограничивайте задержку сверху (maxDelay) и число попыток (maxAttempts). И держите общую «стоимость» повтора в рамках бизнес‑SLA.
Повторы должны уважать общий дедлайн запроса. Если SLA на операцию 2 секунды, не делайте 5 попыток по 1 секунде. Рекомендации:
Сервер тоже может помочь клиенту «повторять умно»:
package retryhttp
import (
"context"
"errors"
"fmt"
"io"
"math"
"math/rand"
"net"
"net/http"
"strconv"
"strings"
"time"
)
type RetryOptions struct {
MaxAttempts int // включая первую попытку
BaseDelay time.Duration // стартовая пауза, например 50ms
MaxDelay time.Duration // максимум паузы между попытками
Multiplier float64 // во сколько раз растёт задержка
PerAttemptTimeout time.Duration // таймаут на одну попытку
RespectRetryAfter bool // учитывать Retry-After
Jitter bool // добавлять случайный разброс
RetryOn func(*http.Response, error) bool
}
// DefaultRetryOn повторяем при сетевых ошибках/таймаутах и кодах 429/502/503/504.
func DefaultRetryOn(resp *http.Response, err error) bool {
if err != nil {
var ne net.Error
if errors.As(err, &ne) {
return true // таймауты/временные сетевые ошибки
}
// разрыв соединения и похожее
if errors.Is(err, io.ErrUnexpectedEOF) || strings.Contains(err.Error(), "connection reset") {
return true
}
return false
}
if resp == nil {
return false
}
switch resp.StatusCode {
case http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
default:
return false
}
}
// DoWithRetry выполняет запрос с повторами и экспоненциальной паузой с джиттером.
func DoWithRetry(ctx context.Context, client *http.Client, req *http.Request, opt RetryOptions) (*http.Response, error) {
if opt.MaxAttempts <= 0 {
opt.MaxAttempts = 3
}
if opt.BaseDelay <= 0 {
opt.BaseDelay = 50 * time.Millisecond
}
if opt.MaxDelay <= 0 {
opt.MaxDelay = 2 * time.Second
}
if opt.Multiplier < 1.0 {
opt.Multiplier = 2.0
}
if opt.RetryOn == nil {
opt.RetryOn = DefaultRetryOn
}
if client == nil {
client = http.DefaultClient
}
// используем отдельный генератор случайных чисел
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
var lastErr error
var resp *http.Response
attempt := 0
for attempt < opt.MaxAttempts {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Клонируем запрос, чтобы можно было повторять с телом.
clonedReq, err := cloneRequest(req)
if err != nil {
return nil, fmt.Errorf("cannot clone request for retry: %w", err)
}
// Таймаут на попытку.
attemptCtx := ctx
var cancel context.CancelFunc
if opt.PerAttemptTimeout > 0 {
attemptCtx, cancel = context.WithTimeout(ctx, opt.PerAttemptTimeout)
defer func() { if cancel != nil { cancel() } }()
}
clonedReq = clonedReq.WithContext(attemptCtx)
resp, err = client.Do(clonedReq)
if err == nil && resp != nil && resp.Body == nil {
// на всякий случай
lastErr = errors.New("empty body")
} else {
lastErr = err
}
// успешный ответ
if !opt.RetryOn(resp, err) {
if err != nil {
return nil, err
}
return resp, nil
}
// если будем повторять — закрываем тело, чтобы не текли соединения
if resp != nil && resp.Body != nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
attempt++
if attempt >= opt.MaxAttempts {
break
}
// Считаем базовую задержку
expDelay := time.Duration(float64(opt.BaseDelay) * math.Pow(opt.Multiplier, float64(attempt-1)))
if expDelay > opt.MaxDelay {
expDelay = opt.MaxDelay
}
// Учитываем Retry-After, если он есть и это включено
if opt.RespectRetryAfter && resp != nil {
if ra := parseRetryAfter(resp.Header.Get("Retry-After")); ra > 0 {
if ra > expDelay {
expDelay = ra
}
}
}
// Добавляем джиттер (полный)
var sleep time.Duration = expDelay
if opt.Jitter {
// случайное значение [0, expDelay]
if expDelay > 0 {
sleep = time.Duration(rng.Int63n(int64(expDelay) + 1))
}
}
// Ждём, но выходим, если общий контекст отменён
t := time.NewTimer(sleep)
select {
case <-ctx.Done():
t.Stop()
return nil, ctx.Err()
case <-t.C:
}
}
if lastErr != nil {
return nil, lastErr
}
return resp, fmt.Errorf("request failed after %d attempts", attempt)
}
func cloneRequest(req *http.Request) (*http.Request, error) {
cloned := req.Clone(req.Context())
if req.Body != nil {
if req.GetBody == nil {
// тело нельзя пересоздать — безопасно повторять только без тела
return nil, fmt.Errorf("request body is not replayable; set GetBody or avoid retries for this method")
}
body, err := req.GetBody()
if err != nil {
return nil, err
}
cloned.Body = body
}
return cloned, nil
}
func parseRetryAfter(v string) time.Duration {
v = strings.TrimSpace(v)
if v == "" {
return 0
}
// сначала пробуем секунды
if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
return time.Duration(secs) * time.Second
}
// затем HTTP-дата
if t, err := http.ParseTime(v); err == nil {
d := time.Until(t)
if d > 0 {
return d
}
}
return 0
}
Пример использования:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequest("GET", "https://api.example.com/health", nil)
resp, err := DoWithRetry(ctx, http.DefaultClient, req, RetryOptions{
MaxAttempts: 5,
BaseDelay: 50 * time.Millisecond,
MaxDelay: 1 * time.Second,
Multiplier: 2,
PerAttemptTimeout: 300 * time.Millisecond,
RespectRetryAfter: true,
Jitter: true,
})
if err != nil {
// логируем и пробрасываем дальше
}
if resp != nil {
defer resp.Body.Close()
}
import random
import time
import requests
from typing import Callable, Optional
class RetryOptions:
def __init__(self,
max_attempts: int = 3,
base_delay: float = 0.05, # секунды
max_delay: float = 2.0,
multiplier: float = 2.0,
per_attempt_timeout: float = 0.3,
respect_retry_after: bool = True,
jitter: bool = True,
retry_on: Optional[Callable[[Optional[requests.Response], Optional[Exception]], bool]] = None):
self.max_attempts = max_attempts
self.base_delay = base_delay
self.max_delay = max_delay
self.multiplier = multiplier
self.per_attempt_timeout = per_attempt_timeout
self.respect_retry_after = respect_retry_after
self.jitter = jitter
self.retry_on = retry_on or default_retry_on
def default_retry_on(resp: Optional[requests.Response], err: Optional[Exception]) -> bool:
if err is not None:
return True # сетевые таймауты/разрывы
if resp is None:
return False
return resp.status_code in (429, 502, 503, 504)
def parse_retry_after(header_value: Optional[str]) -> float:
if not header_value:
return 0.0
v = header_value.strip()
# секунды
if v.isdigit():
return float(v)
# HTTP-дата — requests не парсит автоматически, упростим: игнорируем
return 0.0
def do_with_retry(method: str, url: str, *, session: Optional[requests.Session] = None, opts: RetryOptions = RetryOptions(), **kwargs) -> requests.Response:
s = session or requests.Session()
attempt = 0
last_err = None
while attempt < opts.max_attempts:
try:
resp = s.request(method, url, timeout=opts.per_attempt_timeout, **kwargs)
if not opts.retry_on(resp, None):
return resp
# будем повторять — закрываем содержимое
resp.close()
except Exception as e:
last_err = e
resp = None
attempt += 1
if attempt >= opts.max_attempts:
break
# базовая экспоненциальная задержка
exp_delay = min(opts.base_delay * (opts.multiplier ** (attempt - 1)), opts.max_delay)
# учитываем Retry-After
if opts.respect_retry_after and resp is not None:
ra = parse_retry_after(resp.headers.get('Retry-After'))
if ra > exp_delay:
exp_delay = ra
# джиттер (полный)
sleep = random.uniform(0, exp_delay) if opts.jitter else exp_delay
time.sleep(sleep)
if last_err:
raise last_err
raise RuntimeError(f"request failed after {attempt} attempts")
# пример вызова
resp = do_with_retry("GET", "https://api.example.com/health",
opts=RetryOptions(max_attempts=5, base_delay=0.05, max_delay=1.0, per_attempt_timeout=0.3))
print(resp.status_code)
Замечания по Python:
Поставьте наблюдение на повторы — иначе придётся гадать, «помогает» ли:
Тесты и обстрел:
Итог: экспоненциальные повторы с джиттером — это недорогой и понятный способ повысить устойчивость и конверсию, не раздувая инфраструктуру. Важно соблюдать границы (идемпотентность, дедлайны, Retry‑After) — и повторы будут вашим союзником, а не скрытой DoS‑атакой на собственные сервисы.