Типичная история: ночной скрипт ходит во внешний API, «вроде с таймаутом», а утром в логах дыра на два часа и один воркер, который так и не умер. В коде при этом стоит timeout=5. Как так?
Ниже — разбор того, что на самом деле означает таймаут в requests / urllib3, чем connect отличается от read, когда ретраи помогают, а когда устраивают шторм, и простой паттерн «бюджет времени», который я прогнал на локальном сервере. Версии на стенде: Python 3.13.5, requests 2.34.2, urllib3 2.8.0. Числа в статье — с этого прогона, не из головы.
Главный сюрприз: по умолчанию таймаута нет
Если вызвать requests.get(url) без аргумента timeout, клиент будет ждать ответа столько, сколько живёт сокет. В документации requests это сказано прямо: значение по умолчанию — None, то есть «ждать вечно».
На практике это выглядит так: DNS и TCP прошли, сервер принял соединение и завис при формировании ответа — ваш процесс тоже завис. Никакого исключения через 5, 30 или 300 секунд не будет. Именно поэтому «у нас же requests, он же сам» — опасная иллюзия.
Минимум, который стоит писать всегда:
import requests r = requests.get(url, timeout=5) # url — адрес вашего API
Уже лучше. Но и здесь есть нюанс.
Одно число — это два таймаута сразу
В requests аргумент timeout может быть:
одним числом — тогда и connect, и read ограничены этим значением;
кортежем
(connect_timeout, read_timeout).
Connect — сколько ждать установления TCP-соединения (и TLS, если он есть). Read — сколько ждать между чтениями из сокета после того, как соединение уже есть. Это не «общая длительность запроса».
Чтобы увидеть разницу, поднимем крошечный сервер: /slow отвечает через 2 секунды, /hang молчит минуту.
# lab_server.py (фрагмент) elif self.path.startswith("/slow"): time.sleep(2.0) self._reply(200, b"slow-ok") elif self.path.startswith("/hang"): time.sleep(60) self._reply(200, b"late")
Прогон:
import time import requests # read не успевает: сервер думает 2 с, клиент ждёт 1 с t0 = time.perf_counter() try: requests.get("http://127.0.0.1:18765/slow", timeout=1.0) except requests.exceptions.ReadTimeout as e: print(type(e).__name__, f"{time.perf_counter() - t0:.2f}s") # ReadTimeout 1.00s # того же хватает, если дать 3 с на чтение t0 = time.perf_counter() r = requests.get("http://127.0.0.1:18765/slow", timeout=3.0) print(r.status_code, r.text, f"{time.perf_counter() - t0:.2f}s") # 200 slow-ok 2.00s # connect короткий, read — 1 с; /hang молчит t0 = time.perf_counter() try: requests.get("http://127.0.0.1:18765/hang", timeout=(0.5, 1.0)) except requests.exceptions.ReadTimeout as e: print(type(e).__name__, f"{time.perf_counter() - t0:.2f}s") # ReadTimeout 1.00s
Важный вывод: timeout=5 не означает «весь запрос обязан уложиться в 5 секунд». Если сервер стабильно отдаёт по чуть-чуть каждые 4 секунды, read-таймаут не сработает, а суммарно запрос может идти минутами. Для большинства API это редкость, но для «стриминга» или кривого прокси — вполне реальный сценарий.
Отдельно: отказ в соединении на закрытый порт у меня на стенде всплыл как ConnectionError почти сразу (127.0.0.1:1). Connect-таймаут чаще проявляется на «чёрных дырах», куда пакеты уходят и не возвращаются, а не на ECONNREFUSED.
Ретраи: когда 503 — это нормально
Сетевые сбои и кратковременные 502/503 — как раз тот случай, где повтор имеет смысл. В экосистеме requests это делается через urllib3.util.retry.Retry и HTTPAdapter.
Локальный /flaky первые два раза отвечает 503, на третий — 200:
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retries = Retry( total=5, connect=5, read=5, status=5, backoff_factor=0.2, status_forcelist=(500, 502, 503, 504), allowed_methods=frozenset(["GET", "HEAD", "OPTIONS"]), raise_on_status=False, ) session.mount("http://", HTTPAdapter(max_retries=retries)) r = session.get("http://127.0.0.1:18765/flaky", timeout=2.0) print(r.status_code, r.text) # 200 flaky-ok (на стенде: 3 обращения к хендлеру, ~0.4 с)
Без адаптера тот же URL сразу вернул 503 за один заход. Разница — в одном месте конфигурации, а не в размазанных по коду for-циклах.
Несколько правил, которые стоит зафиксировать явно, а не «как получится»:
Повторять идемпотентное. У
urllib32.8.0 набор по умолчанию —DELETE,GET,HEAD,OPTIONS,PUT,TRACE.POSTтуда не входит. Это хорошо: повторленный POST легко создаёт дубли заказов и платежей. Если ваш API делает POST идемпотентным через ключ идемпотентности — включайте его осознанно, отдельнымallowed_methods.status_forcelist— ваш выбор. Без него ретраи по статусу не поедут: 500 вернётся как обычный ответ (приraise_on_status=False) или как исключение (если включитьraise_on_status=True), но не как «попробуй ещё раз».backoff_factor. Пауза между попытками растёт примерно как{backoff factor} * (2 ** (retry_number - 1)). Для0.2это порядка 0.2, 0.4, 0.8… секунд. Без backoff десять клиентов одновременно долбят полуживой сервис и добивают его окончательно.
Ловушка: ретраи на таймауте удлиняют боль
Retry(total=2, read=2, ...) при read-таймауте 0.5 с на «висящем» хендлере у меня дал три обращения к серверу и исключение примерно через 1.7 с. То есть «таймаут 0.5» внезапно превратился почти в две секунды ожидания, а тип исключения снаружи стал ConnectionError, а не привычный ReadTimeout.
Это не баг вашего кода — так работает связка ретраев и обёртки requests. Но если вы пишете алерт «если ReadTimeout — звони дежурному», такие случаи вы пропустите. Либо ловите базовый requests.exceptions.RequestException, либо оборачивайте клиент сами и нормализуйте ошибки.
Грубая оценка худшего случая:
worst ≈ attempts × per_attempt_timeout + сумма backoff
Именно поэтому «поставим timeout=30 и total=5» на пользовательском запросе — способ получить ответ через пару минут и таймаут уже на балансировщике выше по стеку.
Бюджет времени вместо магии
Удобная ментальная модель: у операции есть дедлайн, а не набор несвязанных таймаутов. Дедлайн один — например, «на весь вызов внешнего API у нас 5 секунд». Каждая попытка берёт только остаток бюджета.
Рабочий каркас без сторонних библиотек поверх requests:
import random import time import requests RETRY_STATUSES = {500, 502, 503, 504} def get_with_budget(url: str, budget: float = 5.0, attempts: int = 4, base: float = 0.2): deadline = time.monotonic() + budget last_error: Exception | None = None for i in range(attempts): remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError(f"budget exhausted after {i} attempts") # на одну попытку — не больше остатка и не больше разумной полки read_timeout = min(remaining, 2.0) connect_timeout = min(0.5, read_timeout) try: response = requests.get( url, timeout=(connect_timeout, read_timeout), ) if response.status_code in RETRY_STATUSES: last_error = RuntimeError(f"status {response.status_code}") else: return response except requests.exceptions.RequestException as exc: last_error = exc sleep_for = min( base * (2 ** i) + random.uniform(0, 0.1), max(0.0, deadline - time.monotonic()), ) time.sleep(sleep_for) raise TimeoutError(f"failed after {attempts} attempts: {last_error}")
На том же /flaky функция вернула 200 с третьей попытки, уложившись примерно в 0.7 с при бюджете 5 с. На /hang с бюджетом 2.5 с она стабильно завершилась TimeoutError: budget exhausted after 2 attempts ровно около 2.50 с — без «вечного» ожидания и без сюрприза в 30 секунд из‑за ретраев.
random.uniform здесь — это jitter. Он специально разводит клиентов, которые упали на одной и той же ошибке в одну миллисекунду. Без jitter вы синхронно бьёте сервер волнами.
Идемпотентность наружу по-прежнему на вас: этот каркас я бы не использовал для POST без ключа идемпотентности.
Что поставить в чеклист команды
Короткий список, который реально снижает число ночных сюрпризов:
Ни одного
requests.get/post/...безtimeoutв прикладном коде. В ревью это такой же стоп-фактор, как SQL из пользовательской строки.Для внешних вызовов предпочитать
timeout=(connect, read)с коротким connect (доли секунды — единицы секунд) и read по SLA партнёра.Ретраи — централизованно (Session + Adapter или общий клиент), с
status_forcelist, backoff и явным списком методов.Считать worst-case:
attempts × timeout + backoffне должен превышать таймаут вызывающей стороны (HTTP-сервер, очередь, cron).Для пользовательских запросов держать бюджет на всю цепочку внешних вызовов, а не «по 10 секунд на каждый».
Логировать номер попытки, статус/тип ошибки и оставшийся бюджет — иначе потом не понять, был ли это один долгий ответ или пять коротких.
Маленький стенд у себя
Если хотите воспроизвести цифры из статьи, достаточно двух файлов: сервер с /slow, /hang, /flaky и клиент с кусками кода выше. Сервер из репозитория статьи слушает 127.0.0.1:18765. Никаких контейнеров и баз не нужно — только стандартная библиотека и requests.
Полный прогон на моей машине занял меньше минуты и сразу показал три привычных заблуждения: «таймаут по умолчанию есть», «одно число = длительность всего запроса», «ретраи просто делают надёжнее, ничего не удлиняя».
Напоследок
Таймауты и ретраи — не «микрооптимизация», а граница ответственности вашего процесса. Без них один зависший upstream превращает воркер в зомби, а с бездумными ретраями вы сами становитесь источником нагрузки. Один общий клиент с бюджетом времени обычно полезнее десятка локальных try/except с sleep(1).
Если в проекте до сих пор встречаются голые requests.get(url) — это хороший повод для точечного рефакторинга и правила в линтере/ревью. Чинится быстро, а ночные дыры в метриках пропадают заметно.