中途卡住:一个常见的 requests 超时处理器漏掉的情况

最后更新于 August 17, 2026
中途卡住:一个常见的 requests 超时处理器漏掉的情况
AI 摘要
在现有 requests 代码里找 bug?重点检查重定向默认假设、只捕获 requests.exceptions.Timeout 却希望覆盖响应体中途卡住的处理器,以及 .text 在没有 charset 的响应上的消费者。机械迁移到 httpx?把异常命名空间改成 httpx.TimeoutException 或更细的阶段类,决定是否开启 follow_redirects,并重新测试解码假设。requests 现有的处理器本来就抓不住上面演示的中途卡顿;迁移并不会凭空制造这个 bug。要写一个需要批量抓很多 URL 的新项目?如果你需要 AsyncClient,以及独立的 connect、read、write 和 pool 超时,httpx 是一个候选。

写出每个人都会写的保护代码:

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

把它指向一台这样的服务器:它会立刻返回状态行和响应头,然后在响应体之前卡住。读取超时了,但这个保护代码没有生效。最终抛出的异常是 ConnectionError,而 ConnectionError 并不是 Timeout 的子类。

同样的卡顿场景放到 httpx 上,会抛出 ReadTimeout,而 ReadTimeout 属于 TimeoutException,对应的异常捕获就能抓住它。

我原本想研究 httpx 和 requests 在哪些方面会影响爬虫,并以为重点会是 async。结果发现,async 反而是列表里最不值得大书特书的部分。

我测试了什么,以及怎么测的

我用本地的 fixture server 跑了 8 组探针,因为客户端自己说“我做了什么”,并不能证明它真的做了什么。服务器会统计 TCP 连接数——也就是每个 socket 被接受时加 1,在请求行解析之前就记录——以及 实际被请求的路径。连接复用和重定向跟随,这两件事都体现在网络层,而网络层才是它们真正生效的地方。

测试环境是:httpx 0.28.1(安装了 http2 额外依赖)、requests 2.34.2、Python 3.14.2、macOS arm64。两者都放在同一个全新的 virtualenv 里,避免互相继承环境痕迹。原始输出见:httpx-probes.json

在第一次运行前,我先把 6 个预测写进了 harness,运行完也没有改动。结果是 3 个命中,2 个错了,1 个只猜对了我想到的情况,却漏掉了真正关键的那个情况。统计表见 prediction-scorecard.json

会悄悄改变行为的默认值

Measured results chart: Defaults that differ between clients

行为requests 2.34.2httpx 0.28.1
默认跟随重定向
模块级调用会复用连接
在响应头之前卡住ReadTimeoutReadTimeout
在响应体中途卡住ConnectionErrorReadTimeout
未声明字符集ISO-8859-1utf-8
HTTP/2不可用需显式开启,可用
分开的 connect/read/write/pool 超时

重定向、socket 复用、异常结果、解码方式和协议协商,都是通过探针观察到的。超时 API 的形态,以及 requests 没有 HTTP/2 开关,属于 API 能力层面的观察。见 httpx-probes.json

其中有 3 行,一旦你切换库,就会在不知不觉间改变代码行为。

重定向:默认不开,而且服务器能证明这一点

一个四跳重定向链,最终落到 /ok

客户端服务器看到的请求数返回状态
requests5 次请求200
httpx1 次请求302
httpx,follow_redirects=True5 次请求200

这里的 5 是 4 次跳转加上最终目标页。我原先的预测写成了 4,那是我没检查算术;方向判断是对的,所以这里直接更正,不把错误藏进正文里。

这是 httpx 文档明确说明的行为,而且这个设计也说得通——重定向本身就是调用方可能想知道的事情。只是它也是迁移时最容易悄悄出问题、却不会抛异常的一点。你的代码收到了 302response.text 为空,解析器找不到任何行,而日志还以为自己拿到了 200 OK……其实并没有;它明明写着 302,只是以前没人去盯这个状态码,因为在 requests 里根本没有这个问题需要盯。

超时这个发现,我一开始判断反了

我原本预测,httpx 会把失败发生在哪个阶段说得更清楚,而 requests 会把两种情况混成一个类。实际上正好相反。

官方参考:Requests timeout 文档

System diagram: Where the Timeout Lands

官方参考:HTTPX timeout 文档

卡顿位置requestshttpx
在状态行之前ReadTimeoutReadTimeout
在响应头之后、响应体中途ConnectionErrorReadTimeout

httpx 给这两种情况都用了同样准确的名称;requests 则把它们拆开了,而且拆到了重试代码所依赖的边界之外。

这并不是我根据类层级“推断”出来的,而是直接把保护代码跑了一遍:

卡顿位置except requests.exceptions.Timeoutexcept httpx.TimeoutException
在状态行之前能捕获能捕获
在响应体中途会以 ConnectionError 逃逸能捕获

timeout-retry-guard.jsonrequests.exceptions.ConnectionError 不是 requests.exceptions.Timeout 的子类;httpx.ReadTimeouthttpx.TimeoutException 的子类。

requests 的异常信息会在 ConnectionError 里面写着 Read timed out.。库其实知道发生了什么,只是没有把这个信息传给类型系统,而 except 语句依赖的正是类型系统。

这里测到的情况是很具体的:响应头已经到达,随后响应体长时间没有进展,最终超过了 read timeout。至于那种会在超时窗口内持续分块传输的响应,包括刻意的流式响应,可能表现不同,这里没有测试。

连接池:差别全在客户端 API 上

10 次 GET,4 种写法,服务器端统计 socket 数量:

方式打开的 socket 数
httpx.get() × 1010
requests.get() × 1010
httpx.Client()1
requests.Session()1

这个结果其实一模一样,但值得单独说清楚,因为关于这两个库最常见的“经验说法”就是:httpx 会池化连接,而 requests 不会。事实并不是这样。模块级调用两者都不会池化;真正负责池化的是它们的 client 对象。如果你今天是在循环里写 requests.get(),那你换成循环里的 httpx.get(),socket 抖动并不会有任何变化。

System diagram: Pooling Lives in the Client

HTTP/2 是显式开启的,而且需要额外依赖

在一个公开的 HTTP/2 endpoint 上,记录结果如下:

官方参考:RFC 9113: HTTP/2

客户端协商结果
httpx.Client(http2=True)HTTP/2
httpx.Client(http2=False)HTTP/1.1
requestsHTTP/1.1,没有这个开关

你需要安装 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)。

这个探针只能证明:在那个 endpoint 上,协议协商是成功的。它并不能证明抓取速度更快;这里没有测试一组对应的 HTTP/1.1 工作负载。

顺序执行和并发执行下的吞吐对比

对一个会 sleep 0.3 秒的 endpoint 发 20 个请求:

模式总耗时Socket 数
同步,1 个 Client6.138 秒1
异步,1 个 AsyncClient0.357 秒20

并发运行只用了 0.357 秒,而顺序运行用了 6.138 秒。与此同时,它打开了 20 个连接,而同步客户端只复用 1 个,所以这个实验改变的是执行模型和有效并发度,而不是单纯隔离库本身的速度。

这才是对这个数字诚实的解释:它测的是在一个故意很慢的 endpoint 上做并发,而不是在测 httpx 本身。任何一个 async 能力正常的客户端,结果都会落在差不多的区间;如果目标是一个很快的 endpoint,差距就会明显缩小。

我没想到要预测的字符集案例

我原以为,如果响应头撒谎——比如 UTF-8 字节却写成 charset=iso-8859-1——两边都会产生一样的乱码。确实如此。两者都会把 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,而 httpx 默认使用 utf-8。于是,在这个缺少 charset 的 fixture 里,两者通过 .text 得到的文本不同;而如果消费者直接用 response.content,拿到的原始字节则是相同的。

内存占用,既然顺手就测一下

峰值 RSS,使用 /usr/bin/time -l,每个单元都用一个全新的进程:

场景requestshttpx
仅导入36.0 MiB30.6 MiB
导入后再发 1 个 GET35.8 MiB40.7 MiB

这些都是单进程快照,而且 requests 在“导入后再发 1 个 GET”时比“仅导入”还略低,说明这里存在运行噪声。它们不足以支持任何方向性的内存结论;要下结论,必须要有重复采样和区间数据。

这对你的选择意味着什么

在现有 requests 代码里找 bug? 重点检查重定向默认假设、只捕获 requests.exceptions.Timeout 却希望覆盖“响应体中途卡住”的处理器,以及 .text 在没有 charset 的响应上的消费者。

机械迁移到 httpx? 把异常命名空间改成 httpx.TimeoutException 或更细的阶段类,决定是否开启 follow_redirects,并重新测试解码假设。requests 现有的处理器本来就抓不住上面演示的中途卡顿;迁移并不会凭空制造这个 bug。

要写一个需要批量抓很多 URL 的新项目? 如果你需要 AsyncClient,以及独立的 connect、read、write 和 pool 超时,httpx 是一个候选。它们各自告诉你等待发生在什么阶段——连接建立、响应体进度、请求上传,还是本地连接池获取——而不是为什么远端主机那样表现。

要写一个小而同步的工具? requests 完全够用,而且无处不在。迁移过去的理由,不是速度。

无论你选哪个, 都请用 client 对象,而不是模块级函数。这个改动在两个库里都是纯收益。

托管式 API 适合放在哪

上面讲的都是抓取层,而抓取层其实是最容易的部分。它们都不会执行 JavaScript,不会处理反爬挑战,也不会把 HTML 变成你真正想要的表格行。

作者注:Thunderbit 是我们用于“输入 URL 即可渲染并提取”的托管方案。它没有在这个 HTTP 客户端 harness 里测试过。只有在你要解决的是页面获取或结构化提取,而不是 HTTP 客户端语义时,才该考虑这类方案。

如果你只是抓普通网页,然后自己解析,那么这两个客户端都仍然适用。托管服务是另一个“自建还是购买”的决策,不是选择这两个库的直接证据。

如果你想看看更广泛的选型,我们整理了 web scraping API roundup 对应的托管方案,以及 open-source scraper pillar 对应的自托管方案。

试试 Thunderbit 做网页数据提取

结论

如果你要为一个新的 Python 抓取层选择方案,并且需要 async 并发、按阶段区分的超时,以及 UTF-8 回退策略,那么在这次测试的约束下,httpx 是我的默认选择。对于成熟的同步代码,requests 依然可用;当迁移风险大于这些收益时,它仍然很合理。代理、重试策略、TLS 指纹、流式传输、上传,以及真实网络波动都没有测试,所以这并不是一个放之四海而皆准的爬虫客户端排行榜。

需要谨慎的核心原因,是重定向默认值;而它之所以危险,恰恰是因为它本身是个很好的设计。显式优于隐式,这句话一直都对,直到那个“隐式”的东西已经成了你旧代码里真正依赖的部分。

预注册的评分表最终是 3 个预测正确、2 个错误、1 个不完整。真正有用的修正,是关于中途卡顿时异常类别的那条;其余选择应当依据实际观测到的行为,而不是评分表叙事。

试试 Thunderbit 做网页数据提取 Get Started Free

常见问题

httpx 真的不会自动跟随重定向吗? 默认不会。对于一个四跳重定向链,服务器只收到了 1 次请求,而返回状态是 302。你可以在每次调用时传 follow_redirects=True,或者在 Client 上统一设置。这是文档明确写明、也是刻意如此设计的;但它仍然是迁移时最容易悄悄出问题的一点,因为失败表现是“解析为空”而不是异常。

只写 except requests.exceptions.Timeout 真的不够吗? 如果服务器是在发送响应头之后才卡住,那确实不够。这个情况会抛出 ConnectionError,而它不是 Timeout 的子类,所以这个保护代码会漏掉它——这点是直接跑出来的,不是推出来的。如果你想两种都抓到,就要捕获 requests.exceptions.RequestException,但也要接受你同时会把一些并非超时的问题一起捕获进来。

httpx 比 requests 快吗? 如果一次只发一个请求,那并没有明显更快——这也不是它的用途。这里看到的 17.2×,测的是对一个响应时间为 0.3 秒的 endpoint 发 20 个并发请求,本质上是在测并发能力。如果你的工作负载是顺序执行,那就别指望速度提升,应该根据默认行为来选。

我需要 http2 额外依赖吗? 只有当你想用 HTTP/2 时才需要;如果你在没有这个依赖的情况下把 http2=True 打开,httpx 会在构造 Client 时抛出 ImportError,并明确提示你安装 httpx[http2]。没有那种“悄悄降级”的隐患。我原本以为会降级,结果是先确认了一下,而不是直接写进文中。

这里没有测试什么? 代理行为,这对爬虫非常重要,而且需要单独的 harness。重试——httpx 本身不带重试逻辑,requests 则来自 urllib3,所以严格来说,公平比较应该是比较两个重试库。TLS 指纹,而反爬系统真正关注的就是这个维度,两者都没有直接解决。流式读取和文件上传。还有一点,这里全程只用了 1 台机器、1 个 Python 版本,而且 8 个探针里有 6 个是在 localhost 上跑的——来自 fixture server 的延迟数字,测的是设计,不是你的真实网络。

Ke
Ke
Thunderbit 首席技术官 | 高级数据科学家与机器学习专家 Ke Shen 拥有近十年的机器学习和数据科学经验,毕业于哥伦比亚大学,曾任 Walmart Labs 高级数据科学家。他在 Python、R、Java 和统计学方面拥有深厚且备受同行认可的专业能力,并分享如何将复杂的 AI 算法从理论落地到生产级架构的实战经验。
目录
Thunderbit · AI 网页数据代理

一键 内提取任意页面数据

深受 250,000+ 用户信赖
提供免费方案
从网页到表格
描述你的需求——Thunderbit 的 AI 代理会帮你抓取并导出到 Excel、Google Sheets、Airtable 或 Notion。可免费开始。
Chrome Store Rating
PRODUCT HUNT#1 Product of the Week