Устойчивость к сбоям в Python: что ломается первым
Retry без джиттера превращает один таймаут БД в лавину запросов. Разбираем, как повторы, таймауты и circuit breaker ведут себя под нагрузкой и где именно рвётся.

Сервис на FastAPI держал 400 RPS, пока сторонний платёжный шлюз не начал отвечать за 8 секунд вместо 200 мс. Через минуту приложение отдавало 503 на всё подряд, включая запросы, которые платёжный шлюз вообще не трогали. Причина не в шлюзе: три повтора с фиксированной паузой в 1 секунду на каждый запрос превратили пул из 20 воркеров в очередь из зависших корутин. Каждый входящий запрос ждал освобождения воркера, воркеры ждали шлюз, шлюз молчал. Это классический каскадный отказ, и почти всегда он рождается из наивно написанного retry.
Разбираем, как устроены три механизма устойчивости в Python — повторы, таймауты и предохранитель (circuit breaker) — что происходит с ними под нагрузкой и где каждый ломается первым. Речь про синхронный и асинхронный код на CPython 3.11+, примеры на httpx 0.27 и tenacity 9.x.
Почему наивный retry опаснее его отсутствия
Повтор кажется бесплатной страховкой: не ответил сервис — спросим ещё раз. Проблема в том, что повтор меняет профиль нагрузки на зависимость в момент, когда ей и так плохо. Три повтора при 10% ошибок дают в среднем в 1.3 раза больше запросов. Но при массовом сбое, когда ошибки идут по всем запросам, повторы умножают трафик ровно на количество попыток — вы бьёте по лежащему сервису с утроенной силой.
Второй эффект тоньше — синхронизация повторов. Если все клиенты используют фиксированную паузу, после сбоя они повторяют запрос в одни и те же миллисекунды. Получается пульсирующая волна: тишина, всплеск, тишина, всплеск. Сервис не успевает восстановиться между волнами.
Retry без экспоненциальной задержки и джиттера — это не защита от сбоя, а генератор синхронизированной DDoS-атаки на вашу же зависимость.
Как устроена задержка с джиттером
Экспоненциальная задержка растит паузу между попытками: 1, 2, 4, 8 секунд. Джиттер добавляет случайность, чтобы клиенты не повторяли синхронно. В tenacity это собирается из готовых блоков:
import httpx
from tenacity import (
retry, stop_after_attempt, wait_exponential_jitter,
retry_if_exception_type,
)
# tenacity 9.x, httpx 0.27
@retry(
# не больше 4 попыток суммарно
stop=stop_after_attempt(4),
# база 1 с, потолок 10 с, к каждой паузе прибавляется случайный джиттер
wait=wait_exponential_jitter(initial=1, max=10, jitter=2),
# повторяем только сетевые сбои, а не бизнес-ошибки
retry=retry_if_exception_type(
(httpx.ConnectError, httpx.ReadTimeout)
),
reraise=True,
)
def fetch_rate(client: httpx.Client, currency: str) -> dict:
response = client.get(
f"https://api.example.com/rates/{currency}",
timeout=2.0, # таймаут обязателен, иначе повтор бесполезен
)
response.raise_for_status()
return response.json()Ключевой момент — фильтр retry_if_exception_type. Повторять ответ 400 или 404 бессмысленно: тело запроса не изменится, вы просто потратите попытки. Повторять стоит только то, что может пройти со второго раза: обрыв соединения, таймаут чтения, 502/503/504. Ответ 429 (Too Many Requests) — отдельный случай: тут нужно уважать заголовок Retry-After, а не свою экспоненту.
Таймаут: без него всё остальное не работает
Retry без таймаута — фикция. Если запрос висит вечно, повтор никогда не запустится, воркер занят навсегда. В httpx таймаут не одно число, а четыре разных лимита, и путаница между ними — частая причина зависаний.
| Параметр httpx.Timeout | Что ограничивает | Типичное значение |
|---|---|---|
| connect | установку TCP-соединения | 2–5 с |
| read | ожидание одного чанка ответа | 5–30 с |
| write | отправку тела запроса | 5–10 с |
| pool | ожидание свободного соединения в пуле | 1–5 с |
Опасность — в read. Он ограничивает паузу между чанками, а не общее время ответа. Если сервер отдаёт по одному байту каждые 4 секунды при read=5, соединение живёт сколько угодно и таймаут не срабатывает. Для жёсткого потолка на весь запрос нужен внешний ограничитель — в асинхронном коде это asyncio.timeout:
import asyncio
import httpx
# Python 3.11+, asyncio.timeout появился в 3.11
async def fetch_with_hard_deadline(url: str) -> dict:
async with httpx.AsyncClient() as client:
# общий дедлайн на весь запрос, чем бы он ни был занят
async with asyncio.timeout(3.0):
response = await client.get(url)
response.raise_for_status()
return response.json()На CPython asyncio.timeout работает через отмену задачи: по истечении дедлайна в корутину бросается CancelledError на ближайшем await. Отсюда граничный случай: если внутри блока есть кусок чистого CPU-кода без await, отмена не сработает, пока управление не вернётся в цикл событий. Таймаут не прерывает вычисления — только ожидания.
Бюджет времени вместо разрозненных таймаутов
Когда retry и таймаут складываются, легко получить абсурд. Таймаут запроса 2 с, четыре попытки, экспонента до 10 с — суммарно клиент может ждать больше 40 секунд, хотя вызывающий код давно отвалился по своему дедлайну. Правильный подход — общий бюджет: сколько времени вся операция имеет право потратить, и каждая попытка укладывается в остаток. Если до дедлайна осталось меньше, чем нужно на попытку — не начинаем её.
Circuit breaker: как перестать бить лежачего
Retry и таймаут спасают отдельный запрос. Предохранитель спасает систему целиком: он замечает, что зависимость стабильно отказывает, и перестаёт слать запросы вообще, отдавая ошибку сразу. Это разрывает цепочку каскадного отказа — воркеры не копятся на зависимом сервисе.
Предохранитель — конечный автомат с тремя состояниями:
- Closed — норма, запросы идут, счётчик ошибок растёт при сбоях.
- Open — порог ошибок превышен, все запросы падают мгновенно без обращения к сервису. Через заданный таймаут переходит в half-open.
- Half-open — пропускается один пробный запрос. Успех — возврат в closed, сброс счётчика. Неудача — снова open.
Готовые реализации есть в библиотеках pybreaker и purgatory. Схематично логика счётчика выглядит так (не production-код, для понимания механизма):
# схематично: суть перехода состояний
import time
class CircuitState:
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class SimpleBreaker:
def __init__(self, fail_max=5, reset_timeout=30):
self.fail_max = fail_max
self.reset_timeout = reset_timeout
self.fail_count = 0
self.state = CircuitState.CLOSED
self.opened_at = 0.0
def allow(self) -> bool:
if self.state == CircuitState.OPEN:
# прошёл ли таймаут для пробного запроса
if time.monotonic() - self.opened_at >= self.reset_timeout:
self.state = CircuitState.HALF_OPEN
return True
return False
return TrueТонкость, о которую спотыкаются в проде: у каждого воркера Gunicorn или Uvicorn своё состояние предохранителя, если оно хранится в памяти процесса. При 8 воркерах порог в 5 ошибок означает до 40 реальных запросов к сбойному сервису до полного размыкания. Общее состояние на все процессы требует внешнего хранилища — Redis, — и это уже сетевой вызов со своими таймаутами. Инвалидация и синхронизация состояния предохранителя между процессами — отдельная тема, здесь только обозначаем проблему.
Что и как мерить
Придумывать цифры бессмысленно — профиль зависит от вашего железа, размера пула и поведения зависимости. Но набор метрик универсален. Снимайте под нагрузкой, близкой к боевой:
- Доля повторов — сколько запросов ушло на вторую и последующие попытки. Рост доли — ранний сигнал, что зависимость деградирует раньше, чем вырастут ошибки на выходе.
- Задержка на 99-м перцентиле, а не среднюю. Retry раздувает именно хвост распределения: медиана в норме, а p99 улетает в сумму всех таймаутов.
- Насыщение пула соединений — сколько времени запросы ждут свободного соединения (тот самый
pool-таймаут). Если ждут — retry уже вредит. - Частота размыканий предохранителя и время в состоянии open. Слишком частые размыкания — порог занижен, слишком редкие при явных сбоях — завышен.
Проверить поведение до продакшена можно нагрузочным тестом с искусственной деградацией зависимости: поднимите мок-сервис, который отвечает с задержкой 5–10 с или отдаёт 503 на 50% запросов, и посмотрите на p99 и насыщение пула. Так вы увидите каскад в контролируемой среде, а не на боевом трафике.
Что делать дальше
Начните с трёх вещей в порядке приоритета. Первое — проставьте явные таймауты на все внешние вызовы; вызов без таймаута опаснее любого другого дефекта устойчивости. Второе — добавьте экспоненциальную задержку с джиттером и фильтр по типу ошибки к существующим повторам. Третье — если есть зависимость, отказ которой роняет весь сервис, поставьте предохранитель и решите, где хранить его состояние при нескольких воркерах. После настройки смотрите на p99 и насыщение пула — именно там каскадный отказ проявится первым.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: tenacity — документация, HTTPX — Timeouts
Частые вопросы
У меня синхронный код на requests, а не httpx. Подходят ли эти приёмы?
asyncio.timeout не отменяет мой запрос, что не так?
Стоит ли повторять запрос при ответе 500?
Предохранитель в памяти процесса — этого достаточно?
Как понять, что мой retry делает хуже, а не лучше?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.