Срыв в середине тела ответа ускользает от привычного обработчика таймаутов requests

Последнее обновление: August 17, 2026
Срыв в середине тела ответа ускользает от привычного обработчика таймаутов requests
AI-сводка
Ищете баги в уже существующем коде на requests? Проверьте предположения о редиректах, обработчики, которые ловят только requests.exceptions.Timeout, но рассчитывают покрыть зависание в середине тела, и потребителей .text, которые получают ответы без charset. Мигрируете механически на httpx? Поменяйте пространства исключений на httpx.TimeoutException или более узкие классы по фазам, решите, включать ли follow_redirects, и заново проверьте предположения о декодировании. Уже существующий обработчик requests и так не ловит показанный mid-body случай; миграция не создаёт именно эту проблему. Пишете новое решение, которое ходит по множеству URL? httpx — хороший кандидат, если вам нужен AsyncClient и отдельные таймауты для connect, read, write и pool.

Напишите стандартную защиту, которую пишут все:

try:
    r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
    retry()

Теперь направьте её на сервер, который сразу отправляет статусную строку и заголовки, а затем зависает до тела ответа. Тайм-аут чтения срабатывает. Но этот обработчик не вызывается. Вылетает ConnectionError, а ConnectionError не является подклассом Timeout.

При таком же зависании httpx выбрасывает ReadTimeout, а это уже TimeoutException, и аналогичный обработчик его перехватывает.

Я начал разбираться, чем httpx отличается от requests в тех местах, где это ломает скрейперы, ожидая написать про async. Но async оказался самым неинтересным пунктом во всём списке.

Что я тестировал и как

Восемь проверок на локальном тестовом сервере, потому что отчёт клиента о том, что он якобы сделал, не доказывает, что он это действительно сделал. Сервер считает TCP-соединения — увеличивает счётчик при каждом принятом сокете, ещё до разбора первой строки запроса — и реально запрошенные пути. Повторное использование соединений и переходы по редиректам — это утверждения про сетевой обмен, а проверять их нужно именно на проводе.

httpx 0.28.1 с extra http2, requests 2.34.2, Python 3.14.2, macOS arm64. Всё запускалось в одном свежем virtualenv, чтобы один клиент не унаследовал следы от другого. Сырые данные: httpx-probes.json.

Шесть прогнозов были внесены в harness ещё до первого прогона и остались там после. Три совпали, два оказались неверными, один был верен для того случая, который я придумал, но упустил тот, который был важен. Учёт ведётся в prediction-scorecard.json.

Дефолты, которые меняют поведение без предупреждения

Measured results chart: Defaults that differ between clients

Поведениеrequests 2.34.2httpx 0.28.1
Следует редиректам по умолчаниюданет
Вызов на уровне модуля переиспользует соединениянетнет
Зависание до заголовковReadTimeoutReadTimeout
Зависание в середине телаConnectionErrorReadTimeout
Если charset не указанISO-8859-1utf-8
HTTP/2недоступенпо явному включению, работает
Раздельные таймауты connect/read/write/poolнетда

Редиректы, переиспользование сокетов, типы исключений, декодирование и согласование протокола были проверены на практике. Форма API таймаутов и отсутствие флага HTTP/2 у requests — это наблюдения по возможностям API. httpx-probes.json.

Три из этих строк изменят поведение вашего кода в момент перехода — и сделают это молча.

Редиректы: выключены по умолчанию, и сервер это доказывает

Цепочка из четырёх редиректов, заканчивающаяся на /ok:

КлиентЧто увидел серверКакой статус вернулся
requests5 запросов200
httpx1 запрос302
httpx, follow_redirects=True5 запросов200

Пятёрка — это четыре перехода плюс конечный адрес. В моём прогнозе стояла четвёрка, что было простой арифметической ошибкой; сама суть утверждения касалась направления, а количество исправлено здесь, а не тихо спрятано в тексте.

Такое поведение httpx задокументировано и вполне оправдано с точки зрения дизайна — редирект может быть важным для вызывающего кода. Но именно это чаще всего и ломает миграцию без каких-либо ошибок. Ваш код получает 302, response.text пустой, парсер не находит ни одной строки, а в логах якобы всё хорошо — 200 OK… хотя на самом деле там 302, и никто не следил за кодом ответа, потому что в requests за этим вообще не приходилось следить.

Таймаут, который я перепутал, — и это оказалось самым важным

Я предполагал, что httpx назовёт фазу, на которой всё сломалось, а requests сгребёт оба случая в один класс. На деле всё наоборот.

Официальная справка: Requests timeout documentation.

System diagram: Where the Timeout Lands

Официальная справка: HTTPX timeout documentation.

Зависаниеrequestshttpx
До статусной строкиReadTimeoutReadTimeout
В середине тела, после отправки заголовковConnectionErrorReadTimeout

httpx называет обе ситуации одинаково и корректно. requests их разделяет — причём ровно по той границе, на которую опирается retry-код.

Последствие видно не по наследованию классов, а по реальному поведению. Я прогнал обработчик:

Зависаниеexcept requests.exceptions.Timeoutexcept httpx.TimeoutException
До статусной строкиперехватываетсяперехватывается
В середине телауходит как ConnectionErrorперехватывается

timeout-retry-guard.json. requests.exceptions.ConnectionError не является подклассом requests.exceptions.Timeout; httpx.ReadTimeout является подклассом httpx.TimeoutException.

Сообщение об ошибке requests внутри ConnectionError говорит Read timed out.. Библиотека понимает, что произошло. Она просто не сообщает это через тип, а именно на тип смотрит ваш except.

Проверенный случай очень конкретный: заголовки уже пришли, а затем прогресс тела остановился достаточно надолго, чтобы превысить read timeout. Ответ, который продолжает отдавать чанки в пределах тайм-аута, включая намеренный стрим, может вести себя иначе — это здесь не тестировалось.

Пул соединений: вся разница — в клиентском API

Десять GET-запросов, четыре варианта, сокеты считаются на стороне сервера:

КакОткрыто сокетов
httpx.get() × 1010
requests.get() × 1010
httpx.Client()1
requests.Session()1

Это одинаково, и это важно проговорить, потому что именно здесь чаще всего возникает бытовой миф: будто бы httpx пулит соединения, а requests — нет. На уровне модуля не пулит никто из них. Пул работает через объект клиента. Если вы сегодня вызываете requests.get() в цикле, то замена на httpx.get() в цикле ничего не изменит в количестве открываемых сокетов.

System diagram: Pooling Lives in the Client

HTTP/2 включается явно и требует extra

На одном публичном HTTP/2-эндпоинте, зафиксированном в артефакте:

Официальная справка: RFC 9113: HTTP/2.

КлиентЧто согласовано
httpx.Client(http2=True)HTTP/2
httpx.Client(http2=False)HTTP/1.1
requestsHTTP/1.1, флага нет

Нужен extra httpx[http2]. Я предположил, что обычный pip install httpx даст клиент, который молча согласует 1.1, и пошёл проверять, прежде чем писать это в текст:

ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.

Исключение возникает при создании Client, ещё до первого запроса, и в сообщении прямо сказано, как это исправить. Это хороший вариант такой ошибки, и я поначалу думал наоборот (http2-extra-missing.json).

Эта проверка подтверждает успешное согласование протокола на данном эндпоинте. Она не доказывает прирост скорости для скрейпинга; сравнимой нагрузки HTTP/1.1 здесь не тестировалось.

Последовательная и конкурентная пропускная способность на тестовом стенде

Двадцать запросов к эндпоинту, который спит 0.3 с:

РежимВремяСокеты
Sync, один Client6.138 с1
Async, один AsyncClient0.357 с20

Конкурентный запуск завершился за 0.357 секунды против 6.138 секунды у последовательного. При этом он открыл двадцать соединений, тогда как синхронный клиент переиспользовал одно, так что эксперимент сравнивает не скорость самой библиотеки, а модель исполнения и фактическую параллельность.

Это честная интерпретация числа. Это замер конкуррентности на намеренно медленном эндпоинте, а не замер преимуществ httpx как такового. Любой клиент с рабочей async-историей окажется примерно в той же зоне, а на быстром эндпоинте разница почти исчезает.

Случай с charset, который я не догадался предсказать

Я предположил, что ответ с ложным заголовком — charset=iso-8859-1 при UTF-8-байтах — даст одинаковую «кашу» в обоих клиентах. Так и вышло. Оба возвращают Café Ubersetzung â naïve résumé, хотя источник содержит Café Ubersetzung — naïve résumé.

Но важнее оказался другой случай, который я не предсказал:

Ответrequests декодирует какhttpx декодирует как
charset=utf-8, UTF-8 байтыкорректнокорректно
charset=iso-8859-1, UTF-8 байтыискажённый текстискажённый текст
charset отсутствуетискажённый тексткорректно

requests по умолчанию откатывается к ISO-8859-1, если в заголовке charset не указан, а httpx по умолчанию использует utf-8. В тестовом случае без charset клиенты поэтому по-разному декодировали .text; если использовать response.content, исходные байты остаются одинаковыми.

Память — раз уж её легко измерить

Пиковый RSS, /usr/bin/time -l, по одному свежему процессу на каждую ячейку:

Ячейкаrequestshttpx
Только импорт36.0 МиБ30.6 МиБ
Импорт плюс один GET35.8 МиБ40.7 МиБ

Это снимки одного процесса, а то, что значение requests с одним GET чуть ниже, чем только при импорте, просто показывает шум измерений. По ним нельзя делать вывод о преимуществах по памяти; для этого нужны повторные замеры и диапазоны.

Что это значит на практике при выборе

Ищете баги в уже существующем коде на requests? Проверьте предположения о редиректах, обработчики, которые ловят только requests.exceptions.Timeout, но рассчитывают покрыть зависание в середине тела, и потребителей .text, которые получают ответы без charset.

Мигрируете механически на httpx? Поменяйте пространства исключений на httpx.TimeoutException или более узкие классы по фазам, решите, включать ли follow_redirects, и заново проверьте предположения о декодировании. Уже существующий обработчик requests и так не ловит показанный mid-body случай; миграция не создаёт именно эту проблему.

Пишете новое решение, которое ходит по множеству URL? httpx — хороший кандидат, если вам нужен AsyncClient и отдельные таймауты для connect, read, write и pool. Эти фазы подсказывают, где именно возникло ожидание — при установлении соединения, в прогрессе тела ответа, при загрузке запроса или при ожидании свободного соединения из пула, — но не объясняют, почему удалённый хост вёл себя именно так.

Пишете что-то небольшое и синхронное? requests вполне подходит и есть везде. Причина переходить — не в скорости.

Что бы вы ни выбрали, используйте объект клиента, а не функцию на уровне модуля. Это единственный пункт из списка, который даёт прямой выигрыш в обеих библиотеках.

Где здесь место управляемому API

Всё выше — это слой получения данных, а он как раз самый простой. Ни один из этих вариантов не рендерит JavaScript, не проходит антибот-защиту и не превращает HTML в нужные вам строки.

Примечание автора: Thunderbit — это наш управляемый вариант для рендеринга и извлечения данных по URL. В этом наборе HTTP-клиентских тестов он не участвовал. Рассматривайте его только тогда, когда проблема — это получение страницы или структурированное извлечение, а не семантика HTTP-клиента.

Если вы просто загружаете обычные страницы и парсите их сами, оба клиента остаются в зоне выбора. Управляемый сервис — это отдельное решение buy vs build, а не аргумент в пользу одной из этих библиотек.

Для более широкого обзора см. наш обзор API для веб-скрейпинга, а также материал про open-source скрейперы для self-hosted вариантов.

Попробовать Thunderbit для извлечения веб-данных

Итог

Для нового Python-слоя получения данных, которому нужны async-конкурентность, фазовые таймауты и UTF-8 как запасной вариант, httpx — мой выбор по умолчанию в рамках протестированных условий. Requests по-прежнему подходит для зрелого синхронного кода, где риск миграции важнее этих преимуществ. Прокси, политики retries, TLS fingerprinting, streaming, загрузка файлов и реальная сетевая вариативность не тестировались, так что это не универсальный рейтинг скрейпинг-клиентов.

Особенно осторожным стоит быть из-за дефолта редиректов, и это реальная опасность именно потому, что сам по себе это хороший дизайнерский выбор. Явное лучше неявного — до тех пор, пока неявное не оказалось несущей частью уже выпущенного кода.

Предварительно зарегистрированная таблица результатов закончилась тремя верными прогнозами, двумя неверными и одним неполным. Полезнее всего оказалось исправление класса исключения для mid-body case; остальное решение должно опираться на наблюдаемое поведение, а не на нарратив scorecard.

Попробовать Thunderbit для извлечения веб-данных Get Started Free

Частые вопросы

Правда ли, что httpx не следует редиректам? По умолчанию — да, не следует. Сервер зафиксировал один запрос для цепочки из четырёх переходов, а ответ вернулся как 302. Передавайте follow_redirects=True для каждого вызова или задайте это один раз на Client. Это задокументировано и сделано намеренно; но именно это чаще всего ломает миграцию тихо, потому что ошибка выглядит как пустой парсинг, а не как исключение.

Достаточно ли except requests.exceptions.Timeout? Нет, если сервер зависает уже после отправки заголовков. В таком случае возникает ConnectionError, а это не подкласс Timeout, поэтому обработчик его пропускает — это показано напрямую, а не выведено логически. Если хотите ловить и это тоже, используйте requests.exceptions.RequestException, но тогда вы поймаете и ошибки, которые вообще не являются таймаутами.

Действительно ли httpx быстрее requests? Не в заметной степени, если речь об одном запросе за раз — для этого он не предназначен. Ускорение в 17.2× в этом тесте — это результат двадцати одновременных запросов к эндпоинту с задержкой 0.3 с, то есть измерение конкуррентности. Если ваш сценарий последовательный, ускорения ждать не стоит; выбирайте по умолчаниям.

Нужен ли extra http2? Только если вам нужен HTTP/2 — и если вы включите http2=True без него, httpx выбросит ImportError уже при создании Client, прямо подсказав установить httpx[http2]. Никакого тихого отката к HTTP/1.1 не будет. Я ожидал именно откат и решил перепроверить, а не писать предположение как факт.

Что здесь не тестировалось? Поведение прокси, а для скрейпинга это очень важно и требует отдельного harness. Retry-механика — у httpx встроенного retry нет, а у requests он приходит через urllib3, так что честное сравнение тут было бы сравнением двух retry-библиотек. TLS fingerprinting, то есть тот слой, на который реально смотрят антиbot-системы, и который ни одна из библиотек не решает. Streaming и загрузка файлов. И ещё: всё это запускалось на одной машине, в одной версии Python, а шесть из восьми проверок использовали localhost — задержки на тестовом сервере показывают дизайн, а не качество вашей сети.

Ke
Ke
Технический директор в Thunderbit | Senior Data Scientist и эксперт по ML Имея почти десятилетний опыт в машинном обучении и data science, Кэ Шэнь — выпускник Колумбийского университета и бывший Senior Data Scientist в Walmart Labs. Обладая глубокой, признанной коллегами экспертизой в Python, R, Java и статистике, он делится проверенными на практике наблюдениями о том, как переводить сложные AI-алгоритмы из теории в production-grade архитектуру.
Содержание
Thunderbit · AI-агент для веб-данных

Извлекай данные с любой страницы за 1 клик

Нам доверяют более 250 000 пользователей
доступен бесплатный тариф
От веб-страницы к таблице
Опиши, что тебе нужно, — AI-агент Thunderbit соберет это и экспортирует в Excel, Google Sheets, Airtable или Notion. Старт бесплатный.
Chrome Store Rating
PRODUCT HUNT#1 Product of the Week