Прямой ответ
Для обычных синхронных вызовов requests остаётся стандартом де-факто: простой API, огромная экосистема, предсказуемость. httpx выбирают, когда нужен один из трёх сценариев: асинхронные вызовы (его AsyncClient — основной async-клиент экосистемы), HTTP/2, или единый sync/async API в одной кодовой базе. Но важнее выбора библиотеки — три дисциплины, без которых любой клиент ненадёжен: явные таймауты на каждый вызов, переиспользование сессии/клиента (пул соединений) и осмысленные ретраи только на идемпотентные операции. Клиент без таймаута — висящий поток; без сессии — лишний TLS-handshake на запрос; с наивным ретраем — удвоение платежей.
Requests: база, которую надо знать глубже дефолтов
Два факта о requests, которые меняют качество кода. Первый — requests.get(...) «в лоб» создаёт и выбрасывает сессию на каждый вызов: TCP+TLS-рукопожатие заново; постоянный клиент обязан использовать Session() (with requests.Session() as s:) — keep-alive и cookie-джар дают кратный выигрыш на сериях запросов. Второй — дефолтного таймаута нет: запрос может висеть вечно; минимум — timeout=(3.05, 27) (соединение/чтение), для стриминга файлов чтение таймаутится на паузы между чанками. Ошибки — через явные проверки: raise_for_status() после каждого вызова (или не после каждого — но осознанно), и отдельный разбор Timeout/ConnectionError для ретраев — 4xx ретраить бессмысленно, 429 — по Retry-After.
httpx: современный API и async
httpx повторяет интерфейс requests (httpx.get, Client) — миграция синхронного кода почти механическая, но добавляет: AsyncClient для asyncio (тот же API — sync/async-код выглядит одинаково, удобно для библиотек); HTTP/2 (http2=True — мультиплексирование на одном соединении, выигрыш на многих мелких запросах к одному хосту); более явные таймауты (httpx.Timeout(5.0, connect=2.0) — объект, а не кортеж, с именованными полями); строгие URL и прокси-конфигурации. Асинхронная сторона требует дисциплины event loop (блокировки и отмены — в статье про asyncio-практику); в синхронном скрипте «на десять запросов» httpx преимуществ не даёт — берите requests.
Ретраи и устойчивость: где логика, а где библиотека
Наивный цикл «упало — повторить» вреден: повтор неидемпотентного POST может удвоить операцию; ретрай без backoff добивает лежащий сервис. Рабочая схема: ретраить только идемпотентное (GET/PUT/DELETE, или POST с idempotency-key) и только на сетевые ошибки/5xx/429; экспоненциальный backoff с джиттером (1с, 2с, 4с + случайность); лимит попыток (3–5); в requests механику даёт HTTPAdapter + urllib3.util.Retry (total=3, backoff_factor=0.5, status_forcelist=[502,503,504]) — транспортный уровень, а бизнес-ретраи — вашим кодом. Для сложных сценариев — tenacity (декораторы ретраев с условиями). Отдельная тема — circuit breaker при массовых вызовах ненадёжного внешнего API, чтобы не класть его и себя.
Тестирование и мокирование вызовов
Тесты не должны ходить в сеть: для requests — responses (запись ожидаемых запросов/ответов), для httpx — встроенный MockTransport или respx; приёмы патчинга — в статье про мок-паттерны. Контрактные тесты (ответ соответствует OpenAPI-схеме) ловят дрейф внешнего API — подход из статьи про контрактную разработку. Для «полевых» проверок — записи реальных ответов в фикстуры (VCR-подход), чтобы тесты жили без живого API.
Практический чек-лист вызова
Каждый внешний вызов в коде проходит пять пунктов: (1) таймаут указан явно (connect + read, под SLA вызываемого сервиса); (2) клиент переиспользуется (Session/AsyncClient живёт на приложение, а не на запрос); (3) статус проверен, ошибки разобраны по типам; (4) тело читается безопасно (для больших ответов — stream=True/потоково, не грузить гигабайт в память); (5) ретраи только идемпотентные, с backoff и лимитом. Запросы с авторизацией — заголовки на клиенте, а не в каждом вызове; секреты — из окружения (правила — в статье про секреты и env).
Практический сценарий: надёжный клиент к внешнему API
Соберём клиент, который переживёт продакшн, а не только демо. Класс PaymentsClient принимает base_url и токен в конструкторе (из настроек приложения, не из кода), внутри создаёт один requests.Session на весь жизненный цикл (keep-alive на серию вызовов), навешивает HTTPAdapter с Retry(total=3, backoff_factor=0.5, status_forcelist=[429, 502, 503, 504], allowed_methods=["GET", "PUT", "DELETE"]) — транспортный ретрай с экспоненциальной паузой, POST в списке сознательно нет (неидемпотентен — для него idempotency-key генерируется на уровне бизнес-кода). Таймауты — объект по умолчанию (3.05, 27): три секунды на соединение, двадцать семь на чтение — под SLA платёжного провайдера; каждый метод может переопределить. Каждый вызов — response.raise_for_status() внутри try, разбор исключений: Timeout и ConnectionError логируются и поднимаются как PaymentsUnavailable (доменное — вызывающий код решает, что делать), HTTPError с 4xx — как PaymentsRejected с кодом ошибки провайдера из тела. Тело парсится через pydantic-модель ответа — дрейф схемы провайдера ловится валидацией, а не через месяц в странном баге. Тесты — на responses/MockTransport: успех, 500 с ретраями, таймаут, 400 с телом ошибки — четыре кейса покрывают все ветки. Такой клиент — сто строк, и ни одна из них не лишняя: каждая строка — это сценарий сбоя, который уже обработан.
Практический сценарий: миграция с requests на httpx без переписывания
Команда решила перейти на httpx ради async-поддержки нового сервиса — и вот как сделать это безопасно. Шаг первый — инвентаризация: все вызовы уже инкапсулированы в клиент-классах (если нет — сначала инкапсуляция, это окупится независимо от библиотеки). Шаг второй — механическая замена в синхронных клиентах: requests.Session() → httpx.Client(), resp.json()/status_code совпадают по API, различия точечные — исключения переименованы (requests.Timeout → httpx.TimeoutException), таймаут задаётся объектом httpx.Timeout(27, connect=3.05) вместо кортежа; тесты на моках правятся под MockTransport вместо responses. Шаг третий — новый async-сервис сразу пишет на httpx.AsyncClient в lifespan-зависимости (клиент живёт на приложение, закрывается на shutdown); тот же код чтения ответов переиспользуется — модели pydantic, разбор ошибок, метрики общие. Шаг четвёртый — прогон нагрузки на стейдже: httpx поверх httpcore ведёт себя на keep-alive сопоставимо, но пул соединений настраивается явно (limits=httpx.Limits(max_connections=50)) — без этого дефолты могут оказаться скромнее ожиданий. Итог: единый HTTP-слой в двух режимах (sync/async), одна кодовая база ошибок и ретраев, и ни одного «большого переписывания» — миграция шла клиент за клиентом за обычные спринты.
FAQ
Что быстрее: requests или httpx? На одиночных запросах — сопоставимо (обе поверх urllib3/httpcore); выигрыш httpx появляется на HTTP/2 и асинхронной конкурентности, выигрыш сессий — у обоих. Решает архитектура (переиспользование, пул), а не марка.
Нужен ли aiohttp, если есть httpx? Для новых проектов httpx удобнее (requests-подобный API, sync+async). aiohttp остаётся в существующих кодовых базах и как серверная сторона — мигрировать «потому что» смысла нет.
Как правильно задать таймауты? Отдельно connect (2–5 с — рукопожатие быстрое или хост недоступен) и read (под SLA операции: 10–30 с для API, минуты для отчётов). Один общий таймаут «30 на всё» маскирует зависшие соединения.
Стоит ли кешировать ответы на клиенте? Да, если данные идемпотентны и допускают устаревание: requests-cache даёт клиентский кеш почти без кода. Не кешируйте персональные/авторизованные ответы без явной политики.
Источники: документация requests и httpx, urllib3 Retry — ссылки в шапке.