Ретраи с задержкой в Python: четыре подхода и их цена
Запрос к платёжному API отвалился по таймауту, повтор через секунду прошёл. Разбираем, как оформить ретраи в Python: голый цикл, tenacity, встроенные механизмы клиентов и backoff — с ценой каждого варианта.

Сервис дёргает внешний платёжный API. Раз в сотню запросов приходит 504 Gateway Timeout или обрывается соединение. Повтор через секунду проходит успешно. Очевидное решение — обернуть вызов в while с time.sleep(1) — на проде превращается в шторм одновременных повторов, когда чужой сервис лежит: все ваши воркеры бьют по нему синхронно, через ровно секунду, и добивают окончательно.
Задача узкая: повторить операцию несколько раз с растущей паузой, отличить временную ошибку от постоянной и не завалить чужой сервис синхронной волной. Разберём четыре подхода на Python 3.11+ и покажем, чем каждый платит. Инвалидация кеша, идемпотентность запросов и circuit breaker — смежные темы, здесь их не трогаем.
Что вообще повторять, а что нет
Прежде чем выбирать библиотеку, договоримся: ретраятся только временные (transient) ошибки. Сетевой таймаут, 503, 429 Too Many Requests, обрыв соединения — да. А 400 Bad Request, 401, 404 и любой ValueError в вашем коде повторять бессмысленно: результат не изменится, вы просто потратите время и три попытки впустую.
Ретрай без фильтра по типу ошибки — это не отказоустойчивость, а бесплатный способ превратить одну ошибку в четыре и получить те же четыре секунды задержки перед неизбежным падением.
Экспоненциальная задержка и джиттер
Фиксированная пауза (1 с, 1 с, 1 с) плоха тем, что все клиенты синхронизируются. Экспоненциальная (1 с, 2 с, 4 с) даёт чужому сервису время подняться. Джиттер — случайная добавка к паузе — разводит клиентов во времени, чтобы они не бомбили сервис синхронной волной после общего сбоя.
Подход 1. Ручной цикл
Никаких зависимостей, всё видно глазами. Годится, когда ретрай нужен в одном-двух местах.
import time
import random
import httpx # httpx 0.27
def fetch_with_retry(url: str, max_attempts: int = 4) -> httpx.Response:
last_exc = None
for attempt in range(max_attempts):
try:
response = httpx.get(url, timeout=5.0)
response.raise_for_status()
return response
except (httpx.TransportError, httpx.HTTPStatusError) as exc:
# не повторяем клиентские ошибки 4xx, кроме 429
if isinstance(exc, httpx.HTTPStatusError):
code = exc.response.status_code
if code < 500 and code != 429:
raise
last_exc = exc
if attempt == max_attempts - 1:
break
# экспоненциальная задержка с джиттером
delay = (2 ** attempt) + random.uniform(0, 1)
time.sleep(delay)
raise last_exc
Плюс — полный контроль и ноль магии в трейсбеке. Минус — этот же блок вы скопируете в пятое место и в одном из них забудете джиттер или перепутаете условие фильтра. Логику фильтрации ошибок легко размазать и рассинхронизировать между вызовами.
Подход 2. Библиотека tenacity
Декоратор поверх функции. Логика ретраев описывается декларативно и отделена от тела функции. Ставится как pip install tenacity (актуальную версию сверьте на PyPI).
from tenacity import (
retry,
stop_after_attempt,
wait_exponential_jitter,
retry_if_exception_type,
)
import httpx # httpx 0.27
@retry(
stop=stop_after_attempt(4),
wait=wait_exponential_jitter(initial=1, max=10),
retry=retry_if_exception_type(httpx.TransportError),
reraise=True,
)
def fetch(url: str) -> httpx.Response:
response = httpx.get(url, timeout=5.0)
response.raise_for_status()
return response
Точные имена функций ожидания (wait_exponential_jitter и другие) и их параметры уточняйте в документации вашей версии tenacity — набор менялся между релизами. По умолчанию tenacity после исчерпания попыток бросает свой RetryError, оборачивая исходное исключение; reraise=True заставляет пробросить оригинал — обычно это то, что вам нужно в логах.
Плюс — читаемость и переиспользуемость: политику ретраев видно в одном месте, её можно вынести в общий декоратор. Минус — фильтр по HTTP-коду (повторять 429 и 5xx, но не 4xx) через готовые предикаты выражается менее прямо, чем в ручном цикле; часто под это пишут свою функцию-предикат.
Подход 3. Ретраи в самом клиенте
Многие HTTP-клиенты умеют ретраить на транспортном уровне, до вашего кода. У requests это связка с urllib3 через Retry и HTTPAdapter.
import requests # requests 2.32
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry_policy = Retry(
total=3,
backoff_factor=1, # задержка: backoff_factor * (2 ** (n-1))
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods={"GET", "POST"}, # POST только если запрос идемпотентен
respect_retry_after_header=True, # уважать Retry-After от сервера
)
session = requests.Session()
adapter = HTTPAdapter(max_retries=retry_policy)
session.mount("https://", adapter)
session.mount("http://", adapter)
response = session.get("https://example.com/api", timeout=5.0)
Плюс — ретраи применяются ко всем запросам через эту сессию автоматически, и respect_retry_after_header корректно отрабатывает заголовок Retry-After при 429. Минус — Retry из urllib3 ретраит по HTTP-статусам и сетевым ошибкам, но не по вашей логике: если 200 OK вернул тело с {"status": "error"}, транспорт этого не заметит. И джиттер у urllib3 включается отдельным флагом — сверьте его наличие и имя в документации вашей версии.
Подход 4. Библиотека backoff
Ещё один декоратор, ориентированный именно на экспоненциальные задержки. Ставится как pip install backoff.
import backoff
import httpx # httpx 0.27
@backoff.on_exception(
backoff.expo,
httpx.TransportError,
max_tries=4,
jitter=backoff.full_jitter,
)
def fetch(url: str) -> httpx.Response:
response = httpx.get(url, timeout=5.0)
response.raise_for_status()
return response
backoff проще tenacity по числу сущностей: одна функция-генератор задержек (expo) и список исключений. Джиттер по умолчанию включён и это full_jitter — задержка случайна в диапазоне от нуля до расчётной. Минус — меньше готовых предикатов и условий остановки, чем у tenacity; для сложной логики вы упрётесь в потолок раньше.
Сравнение
| Подход | Зависимость | Джиттер | Async | Когда выбирать |
|---|---|---|---|---|
| Ручной цикл | нет | руками | руками | один-два вызова, полный контроль |
| tenacity | tenacity | есть | да | много мест, сложные условия остановки |
| urllib3 Retry | requests/urllib3 | флагом | нет | ретрай на весь клиент по HTTP-статусам |
| backoff | backoff | по умолчанию | да | простой экспоненциальный ретрай по исключениям |
Когда что выбирать
- Один вызов, зависимостей не хочется — ручной цикл. Но вынесите его в общую функцию сразу, а не после третьего копипаста.
- Ретрай нужен на все запросы к API и логика завязана на HTTP-коды —
Retryв клиенте. Он единственный из четырёх штатно уважаетRetry-After. - Нужно ретраить по бизнес-условию (пустой ответ, флаг в теле, конкретное исключение приложения) — tenacity или backoff, потому что транспортный ретрай сюда не дотянется.
- Сложные условия остановки и ожидания, разные политики в разных местах — tenacity, у него самый богатый набор предикатов.
Что ломается на проде
- Ретрай без общего таймаута. Четыре попытки по 5 с плюс паузы 1+2+4 — это до 27 секунд на один вызов. Если сверху висит HTTP-хендлер с таймаутом 30 с, вы упрётесь в него и вернёте пользователю ошибку после долгого зависания. Ограничивайте суммарное время, а не только число попыток.
- Ретрай неидемпотентного POST. Повтор запроса на списание денег без ключа идемпотентности спишет дважды. Ретраить POST можно, только если сервер поддерживает
Idempotency-Keyили операция безопасна по своей природе. - Ретрай в очереди задач поверх ретрая в коде. Если Celery уже ретраит задачу, а внутри неё tenacity ретраит вызов, вы перемножаете попытки: 3×4 = 12 обращений к чужому сервису вместо ожидаемых трёх.
- Логи молчат. Ретрай, который тихо проглатывает ошибки и не пишет в лог факт повтора, скрывает деградацию: сервис вроде работает, но каждый второй запрос идёт со второй попытки. Логируйте номер попытки и причину.
Что сделать дальше
Начните с ограничения суммарного времени: добавьте к любому из подходов верхнюю границу общей длительности, а не только счётчик попыток. Затем заведите метрику числа ретраев на запрос и выведите её в мониторинг — рост доли повторов сигналит о деградации внешнего сервиса раньше, чем полное падение. Если один и тот же адрес стабильно отвечает ошибками, ретраи уже не спасают: следующий шаг — circuit breaker, который перестанет долбить мёртвый сервис и даст ему передышку. Это отдельная тема.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: tenacity — официальная документация, urllib3 Retry — API reference
Частые вопросы
У меня async-код на httpx.AsyncClient, эти подходы подойдут?
Точные имена wait_exponential_jitter и параметров tenacity верные?
Ретрай не срабатывает, ошибка пробрасывается сразу — почему?
Стоит ли ретраить POST-запросы?
Какую задержку и сколько попыток ставить по умолчанию?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.