Retry и backoff в Python: как повторять запросы правильно
Наивный retry в цикле for кладёт чужой сервис и не защищает вас от каскадных отказов. Разбираем экспоненциальный backoff, джиттер и что выбрать между tenacity, urllib3 и своим кодом.

Клиент отправляет запрос к платёжному API, получает 503, тут же повторяет — и так пять раз подряд без паузы. Если так делают триста экземпляров вашего сервиса одновременно, вы не восстанавливаете связь, а добиваете сервис, который и так лежит. Это называется retry storm, и на проде он превращает пятисекундную деградацию в получасовой инцидент. Разберём, как повторять сетевые запросы в Python так, чтобы retry помогал, а не мешал: экспоненциальная задержка, джиттер, ограничение попыток и что из этого уже готово в библиотеках.
Какие ошибки вообще стоит повторять
Повтор имеет смысл только для транзиентных ошибок — тех, что пройдут сами. Повторять то, что детерминированно вернёт ту же ошибку, — потеря времени и лишняя нагрузка.
- Повторяем: таймауты соединения и чтения, отказ в установке соединения (
ConnectionError), HTTP-коды 502, 503, 504, а также 429 (Too Many Requests) — но только с уважением к заголовкуRetry-After. - Не повторяем: 400, 401, 403, 404, 422 — это ошибки запроса или прав, повтор ничего не изменит.
- Отдельный случай: 500 без деталей. Иногда транзиентный, иногда — необработанное исключение на сервере. Повторять осторожно и с малым числом попыток.
Ключевой вопрос до всякого кода — идемпотентен ли запрос. GET, PUT, DELETE обычно идемпотентны: повтор безопасен. POST, создающий заказ или списывающий деньги, — нет. Повтор POST без ключа идемпотентности может создать два заказа.
Retry без ключа идемпотентности на POST — это не отказоустойчивость, а генератор дублей. Сначала научите API принимать Idempotency-Key, потом включайте повторы.
Почему пауза между попытками должна расти
Наивный вариант — цикл с фиксированной паузой:
import time
import requests
# Плохой пример: фиксированная пауза, повтор чего угодно
def fetch_bad(url):
for _ in range(5):
resp = requests.get(url, timeout=5)
if resp.status_code == 200:
return resp
time.sleep(1) # одинаковая пауза для всех клиентов
raise RuntimeError("failed")
Проблем две. Первая — фиксированная пауза не даёт перегруженному сервису восстановиться: нагрузка остаётся постоянной. Вторая — если тысяча клиентов упала одновременно, они все проснутся через ровно 1 секунду и ударят синхронно. Это thundering herd.
Экспоненциальный backoff решает первую проблему: пауза растёт как base * 2 ** attempt. Джиттер — случайная добавка к паузе — решает вторую, размазывая повторы по времени.
Формула с джиттером
Рабочий вариант «full jitter»: задержка — случайное число от нуля до текущего потолка backoff.
import random
import time
import requests
# requests 2.x
def fetch_with_backoff(url, max_attempts=5, base=0.5, cap=30.0):
retryable = {502, 503, 504}
for attempt in range(max_attempts):
try:
resp = requests.get(url, timeout=5)
except (requests.ConnectionError, requests.Timeout):
resp = None # сетевая ошибка — повторяем
else:
if resp.status_code not in retryable:
resp.raise_for_status()
return resp
if attempt == max_attempts - 1:
break
# потолок паузы растёт экспоненциально, но не выше cap
ceiling = min(cap, base * (2 ** attempt))
# full jitter: берём случайную точку от 0 до потолка
sleep_for = random.uniform(0, ceiling)
time.sleep(sleep_for)
raise RuntimeError(f"{url}: не удалось за {max_attempts} попыток")
Проверка: при base=0.5 потолки по попыткам будут 0.5, 1, 2, 4, 8 секунд, а реальная пауза — случайная величина внутри каждого потолка. cap не даёт паузе уйти в минуты.
Уважайте Retry-After
На 429 и иногда 503 сервер присылает заголовок Retry-After — в секундах или в виде HTTP-даты. Игнорировать его — верный способ получить бан по rate limit. Если заголовок есть, он важнее вашей формулы: спите ровно столько, сколько просят.
Свой код или библиотека
Три распространённых способа в экосистеме Python, у каждого своя цена.
| Способ | Что даёт | Чем платите |
|---|---|---|
| Свой цикл (как выше) | Полный контроль, ноль зависимостей | Легко забыть джиттер, cap, разбор Retry-After; дублируется по кодовой базе |
urllib3.util.retry.Retry через HTTPAdapter | Retry на уровне пула соединений requests; есть backoff_factor, status_forcelist, respect_retry_after_header | Только HTTP-слой; тонкая настройка условий ограничена |
tenacity | Декоратор, гибкие политики: retry по типу исключения, wait-стратегии с джиттером, stop-условия, колбэки | Внешняя зависимость; проще перемудрить с конфигурацией |
urllib3 внутри requests
Если вы уже используете requests и хотите повторы «под капотом» без обёрток над каждым вызовом, используйте Retry из urllib3. Актуальные имена параметров сверяйте в документации вашей версии urllib3 (в новых версиях — allowed_methods, в старых был method_whitelist).
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# requests 2.x, urllib3 2.x
retry = Retry(
total=5,
backoff_factor=0.5, # пауза = backoff_factor * (2 ** (n-1))
status_forcelist=[502, 503, 504],
allowed_methods={"GET", "PUT", "DELETE"}, # POST намеренно исключён
respect_retry_after_header=True,
)
session = requests.Session()
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
resp = session.get("https://api.example.com/data", timeout=5)
Обратите внимание: у самого Retry джиттер по умолчанию не такой богатый, как в tenacity; в urllib3 2.x появился параметр backoff_jitter. Точное поведение и наличие параметра проверяйте в документации своей версии — между 1.26.x и 2.x детали менялись.
tenacity, когда условий много
Если повторять нужно не только по HTTP-коду, а, скажем, по конкретному исключению драйвера БД или по значению в теле ответа, декларативнее взять tenacity.
import requests
from tenacity import (
retry, stop_after_attempt, wait_exponential_jitter,
retry_if_exception_type,
)
# tenacity 8.x
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential_jitter(initial=0.5, max=30),
retry=retry_if_exception_type((requests.ConnectionError, requests.Timeout)),
reraise=True,
)
def fetch(url):
resp = requests.get(url, timeout=5)
resp.raise_for_status() # 4xx/5xx поднимут HTTPError
return resp
Здесь reraise=True отдаёт наружу исходное исключение, а не обёртку RetryError — на проде это удобнее для трейсов. Имена wait_exponential_jitter и его параметров сверяйте с документацией tenacity: API стратегий ожидания дополнялся между минорными версиями.
Когда что выбирать
- Один-два места с простыми HTTP-повторами —
urllib3.Retryна сессии. Меньше кода, повтор прозрачен. - Сложные условия, не только HTTP (БД, брокеры, бизнес-логика в ответе) — tenacity с явными предикатами.
- Асинхронный код на asyncio — свой цикл с
asyncio.sleepили tenacity в async-режиме; синхронныйtime.sleepв корутине заблокирует весь event loop. - Массовые вызовы к одному сервису — retry мало, нужен circuit breaker: после серии отказов перестать долбить сервис вовсе. Это отдельная тема, здесь не разворачиваем.
Что ломается на практике
- Retry поверх retry. Настроили
urllib3.Retry, обернули вызов в tenacity, а сверху ещё цикл в вызывающем коде — итоговое число попыток перемножается. Пять на пять на три — это 75 запросов вместо пяти. Держите повтор в одном слое. - Нет общего таймаута. Пять попыток по 30 секунд плюс backoff — это минуты ожидания, за которые отвалится вышестоящий вызов. Ограничивайте суммарное время, а не только число попыток.
- Повтор неидемпотентного POST. Без
Idempotency-Keyвы создаёте дубли. Именно поэтому в примере с urllib3 POST исключён изallowed_methods. - Backoff без cap. При десяти попытках
0.5 * 2 ** 9— это 256 секунд паузы. Всегда ставьте потолок. - Джиттер забыли. Формально backoff есть, но все клиенты просыпаются синхронно. Под нагрузкой это видно как ровные пики RPS с интервалом в шаг backoff.
Что настроить дальше
Retry — только один слой защиты. После него смотрите в сторону:
- Метрик: считайте число повторов и долю запросов, дошедших до успеха только со второй попытки. Рост этой доли — ранний сигнал деградации зависимости.
- Circuit breaker: когда сервис устойчиво отвечает ошибками, повторы бесполезны и вредны. Библиотека
pybreakerили встроенные механизмы вашего HTTP-клиента. - Общего дедлайна: прокидывайте оставшийся бюджет времени через всю цепочку вызовов, чтобы retry не съедал чужой таймаут.
Начните с малого: добавьте экспоненциальный backoff с джиттером и cap в одно место, где чаще всего ловите таймауты, измерьте долю повторов до и после — и только потом обобщайте политику на весь сервис.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: urllib3 — Retry API reference, tenacity — официальная документация
Частые вопросы
А если у меня urllib3 версии 1.26, а не 2.x?
Почему мой backoff в asyncio блокирует весь сервис?
Можно ли повторять POST-запросы?
Сколько попыток и какой base ставить?
Retry не помогает, ошибки всё равно летят — что не так?
Нужен ли circuit breaker, если уже есть backoff?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.