Настройка таймаутов, exponential backoff, jitter, retry budgets
«Надейся на лучшее, готовься к худшему». Таймауты и retry — первая линия защиты от long tail latency.
В распределённых системах запросы могут замедляться или «зависать» по множеству причин:
Без таймаута клиент будет ждать вечно (или до системного таймаута — минуты/часы).
Таймаут — максимальное время ожидания ответа. Если ответ не получен, запрос прерывается с ошибкой.
import requests
from requests.exceptions import Timeout
# Плохо: нет таймаута — может зависнуть навсегда
response = requests.get('https://api.example.com/data')
# Хорошо: явный таймаут
try:
response = requests.get('https://api.example.com/data', timeout=5.0)
except Timeout:
# Обрабатываем таймаут: retry, fallback, error response
logger.warning("Request timed out")| Тип | Что контролирует | Пример |
|---|---|---|
| Connect timeout | Время на установление соединения (TCP handshake + TLS) | 2-5 секунд |
| Read timeout | Время на получение ответа после установления соединения | 5-30 секунд |
| Write timeout | Время на отправку запроса | 5-10 секунд |
| Total timeout | Общее время на всё (connect + write + read) | 10-60 секунд |
import aiohttp
import asyncio
async def fetch_with_timeouts(url):
"""Разные таймауты для разных фаз запроса."""
timeout = aiohttp.ClientTimeout(
total=30, # Общее время
connect=5, # Установка соединения
sock_read=10, # Чтение ответа
sock_connect=5 # TCP handshake
)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as response:
return await response.text()
# asyncio.run(fetch_with_timeouts('https://api.example.com'))Неправильно:
Правильно:
# Конфигурация таймаутов на основе метрик
TIMEOUTS = {
# Read операции: быстрые, p99 = 50 мс
'read_default': 0.1, # 100 мс
# Write операции: медленнее, p99 = 200 мс
'write_default': 0.5, # 500 мс
# Поиск: сложный query, p99 = 500 мс
'search': 1.0, # 1 секунда
# Отчёт: тяжёлая операция, p99 = 5 секунд
'report': 10.0, # 10 секунд
# Внешние API: непредсказуемы
'external_api': 5.0, # 5 секунд
}
class DatabaseClient:
def __init__(self):
self.timeouts = TIMEOUTS
async def get_user(self, user_id):
# Быстрая read операция
timeout = self.timeouts['read_default']
return await self._query(
'SELECT * FROM users WHERE id = ?',
user_id,
timeout=timeout
)
async def generate_report(self, report_type):
# Тяжёлая операция
timeout = self.timeouts['report']
return await self._query(
'CALL generate_report(?)',
report_type,
timeout=timeout
)✅ Retry имеет смысл для временных (transient) ошибок:
❌ Retry бесполезен для постоянных ошибок:
import random
from enum import Enum
class ErrorType(Enum):
TRANSIENT = "transient" # Можно retry
PERMANENT = "permanent" # Retry бесполезен
UNKNOWN = "unknown" # Требуется эвристика
def classify_error(status_code, exception=None):
"""Классифицирует ошибку для решения о retry."""
if status_code in (429, 503, 502, 504):
return ErrorType.TRANSIENT
if status_code in (400, 401, 403, 404):
return ErrorType.PERMANENT
if isinstance(exception, (TimeoutError, ConnectionError)):
return ErrorType.TRANSIENT
if status_code >= 500:
# 5xx обычно transient, но может быть и багом
return ErrorType.UNKNOWN
return ErrorType.PERMANENT
def should_retry(status_code, attempt, max_retries):
"""Решает, стоит ли retry."""
if attempt >= max_retries:
return False
error_type = classify_error(status_code)
if error_type == ErrorType.PERMANENT:
return False # Retry бесполезен
if error_type == ErrorType.UNKNOWN:
# Эвристика: retry только 1 раз для неизвестных ошибок
return attempt == 0
return True # TRANSIENT — retry всегдаПроблема: если 1000 клиентов одновременно retry, они создадут thundering herd и добьют восстанавливающийся сервис.
Решение: exponential backoff — увеличиваем задержку экспоненциально.
import time
import random
def exponential_backoff(attempt, base_delay_ms=100, max_delay_ms=10000):
"""
Вычисляет задержку с exponential backoff.
attempt 0 → 100 мс
attempt 1 → 200 мс
attempt 2 → 400 мс
attempt 3 → 800 мс
...
attempt 7 → 12800 мс → max_delay_ms (10000 мс)
"""
delay = base_delay_ms * (2 ** attempt)
return min(delay, max_delay_ms)
def retry_with_backoff(func, max_retries=5, *args, **kwargs):
"""Retry с exponential backoff."""
last_exception = None
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except (TimeoutError, ConnectionError) as e:
last_exception = e
if attempt == max_retries:
break # Последняя попытка исчерпана
delay_ms = exponential_backoff(attempt)
logger.warning(
f"Attempt {attempt + 1} failed, retrying in {delay_ms}ms",
exc_info=True
)
time.sleep(delay_ms / 1000)
raise last_exception
# Пример использования
def call_external_api():
# Симуляция нестабильного API
if random.random() < 0.7:
raise TimeoutError("API timeout")
return {"data": "success"}
# result = retry_with_backoff(call_external_api, max_retries=5)Проблема: даже с exponential backoff, клиенты могут синхронизироваться (особенно если они стартовали одновременно).
Решение: добавить случайность (jitter) к задержке.
def exponential_backoff_with_jitter(attempt, base_delay_ms=100, max_delay_ms=10000):
"""
Exponential backoff с full jitter.
Формула: random(0, min(base * 2^attempt, max_delay))
Это предотвращает синхронизацию retry от множества клиентов.
"""
delay = base_delay_ms * (2 ** attempt)
capped_delay = min(delay, max_delay_ms)
# Full jitter: случайная задержка от 0 до capped_delay
jittered_delay = random.uniform(0, capped_delay)
return jittered_delay
# Визуализация разницы
def compare_backoff_strategies():
"""Показывает разницу между strategies."""
import statistics
attempts = 5
no_jitter = [exponential_backoff(i) for i in range(attempts)]
with_jitter = [exponential_backoff_with_jitter(i) for _ in range(100) for i in range(attempts)]
print("Без jitter (детерминировано):")
for i, delay in enumerate(no_jitter):
print(f" Attempt {i}: {delay} мс")
print("\nС jitter (статистика за 100 запусков):")
for i in range(attempts):
samples = [exponential_backoff_with_jitter(i) for _ in range(100)]
print(f" Attempt {i}: avg={statistics.mean(samples):.1f} мс, "
f"min={min(samples):.1f} мс, max={max(samples):.1f} мс")
# compare_backoff_strategies()Результат:
Без jitter (детерминировано):
Attempt 0: 100 мс
Attempt 1: 200 мс
Attempt 2: 400 мс
Attempt 3: 800 мс
Attempt 4: 1600 мс
С jitter (статистика за 100 запусков):
Attempt 0: avg=48.3 мс, min=0.5 мс, max=99.8 мс
Attempt 1: avg=97.1 мс, min=1.2 мс, max=199.5 мс
Attempt 2: avg=195.4 мс, min=2.3 мс, max=399.1 мс
Attempt 3: avg=392.8 мс, min=5.1 мс, max=798.7 мс
Attempt 4: avg=785.2 мс, min=10.2 мс, max=1595.3 мс
Проблема: retry увеличивает нагрузку на сервис. При массовых сбоях retry могут усугубить ситуацию, создавая cascade failure.
Решение: retry budget — лимит на количество retry в единицу времени.
import time
from collections import deque
class RetryBudget:
"""
Ограничивает количество retry для предотвращения cascade failure.
Принцип: retry budget = процент от успешных запросов.
Например, 20% budget означает: на 100 успешных запросов можно 20 retry.
"""
def __init__(self, budget_percent=20, window_seconds=60):
self.budget_percent = budget_percent
self.window_seconds = window_seconds
self.successes = deque() # (timestamp, count)
self.retries = deque() # (timestamp, count)
def _cleanup(self, timestamps):
"""Удаляет старые записи за окном."""
cutoff = time.time() - self.window_seconds
while timestamps and timestamps[0][0] < cutoff:
timestamps.popleft()
def _count_total(self, timestamps):
"""Суммирует count за окно."""
return sum(count for _, count in timestamps)
def record_success(self):
"""Записать успешный запрос."""
now = time.time()
self._cleanup(self.successes)
if self.successes and self.successes[-1][0] == now:
# Обновляем текущую секунду
_, count = self.successes.pop()
self.successes.append((now, count + 1))
else:
self.successes.append((now, 1))
def can_retry(self):
"""Проверяет, есть ли бюджет на retry."""
now = time.time()
self._cleanup(self.successes)
self._cleanup(self.retries)
total_successes = self._count_total(self.successes)
total_retries = self._count_total(self.retries)
# Бюджет: retries <= budget_percent% от successes
max_retries = int(total_successes * self.budget_percent / 100)
return total_retries < max_retries
def record_retry(self):
"""Записать использованный retry."""
if not self.can_retry():
raise RuntimeError("Retry budget exhausted")
now = time.time()
self._cleanup(self.retries)
if self.retries and self.retries[-1][0] == now:
_, count = self.retries.pop()
self.retries.append((now, count + 1))
else:
self.retries.append((now, 1))
# Интеграция с retry логикой
retry_budget = RetryBudget(budget_percent=20, window_seconds=60)
def call_with_budgeted_retry(func, max_retries=5, *args, **kwargs):
"""Retry с ограничением по бюджету."""
last_exception = None
for attempt in range(max_retries + 1):
try:
result = func(*args, **kwargs)
retry_budget.record_success()
return result
except (TimeoutError, ConnectionError) as e:
last_exception = e
if attempt == max_retries:
break
if not retry_budget.can_retry():
logger.warning("Retry budget exhausted, giving up")
break
retry_budget.record_retry()
delay_ms = exponential_backoff_with_jitter(attempt)
time.sleep(delay_ms / 1000)
raise last_exceptionПроблема: retry неидемпотентных операций может создать дубликаты.
Пример:
# Опасно: retry может создать два заказа
def create_order(user_id, amount):
response = requests.post('/api/orders', json={'user_id': user_id, 'amount': amount})
if response.status_code == 503:
# Retry создаст второй заказ!
response = requests.post('/api/orders', json={'user_id': user_id, 'amount': amount})Решение: idempotency key — уникальный идентификатор запроса.
import uuid
class IdempotentClient:
def __init__(self, api_base_url):
self.api_base_url = api_base_url
self.session = requests.Session()
def create_order(self, user_id, amount, idempotency_key=None):
"""
Создаёт заказ с idempotency key.
Сервер гарантирует: одинаковые idempotency key → одинаковый результат.
"""
idempotency_key = idempotency_key or str(uuid.uuid4())
headers = {
'Idempotency-Key': idempotency_key,
'Content-Type': 'application/json'
}
response = self.session.post(
f'{self.api_base_url}/api/orders',
json={'user_id': user_id, 'amount': amount},
headers=headers,
timeout=5.0
)
return response.json()
# Использование
client = IdempotentClient('https://api.example.com')
# Первый запрос создаст заказ
order1 = client.create_order(user_id=123, amount=100, idempotency_key='order-123-20260314')
# Retry с тем же key вернёт тот же заказ (не создаст дубликат)
order2 = client.create_order(user_id=123, amount=100, idempotency_key='order-123-20260314')
assert order1['id'] == order2['id'] # Один и тот же заказ# ❌ Плохо
requests.get(url)
# ✅ Хорошо
requests.get(url, timeout=5.0)# Измерьте p99 latency сервиса
# Установите таймаут = p99 + 20-50% buffer
# Если p99 = 100 мс → таймаут 150 мс
# Если p99 = 1 секунда → таймаут 1.5 секундыRETRYABLE_STATUS_CODES = {429, 502, 503, 504}
NON_RETRYABLE_STATUS_CODES = {400, 401, 403, 404}# ❌ Плохо: фиксированная задержка
time.sleep(1.0)
# ✅ Хорошо: backoff + jitter
delay = random.uniform(0, min(100 * (2 ** attempt), 10000))
time.sleep(delay / 1000)# Для предотвращения cascade failure
if not retry_budget.can_retry():
# Используйте fallback или верните ошибку
return fallback_response()# Генерируйте idempotency key на клиенте
idempotency_key = f"{operation_type}-{entity_id}-{timestamp}"
headers['Idempotency-Key'] = idempotency_keyВ следующей теме рассмотрим Circuit Breaker — паттерн автоматического отключения неисправных сервисов.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.