requests 设置了 timeout 却还在傻等:超时到底卡在哪一步

编程狮(w3cschool.cn) 2026-10-29 07:05:53 浏览数 (28)
反馈

requests 的 timeout 只约束两段:建立连接能等多久,以及两次收到数据之间的空档能等多久;它并不限制整次请求的总耗时。所以你写上 timeout=5 之后,脚本照样能挂十几分钟——服务端只要每隔几秒慢慢吐一个字节,读超时就永远不会触发。本文把这个「覆盖了什么、没覆盖什么」讲清楚,再给出三种真正能兜住总耗时的做法,每段都配可以直接在本机复现的最小代码。适用范围:requests 2.x 搭配 urllib3 1.26 至 2.x,Windows、Linux、macOS 行为一致。

requests 设了 timeout却还在傻等?

很多人第一反应是「超时参数失效了」,但更常见的原因是理解错位:我们把「一次请求的总时限」理解成了 timeout 的能力,而它提供的是「连接时限」加「读取间隔时限」两件事。二者的差别决定了你是否需要用重试次数、退避策略和会话复用来补齐剩下的部分。看懂这层差别,后面三种方案的选择就不再纠结。

一、先把 timeout 到底管哪一段看清(适用与边界)

timeout 接受两种写法。传单个数字时,requests 会把它同时当成连接超时和读取超时,也就是 timeout=5 等价于传入一个元组 (5, 5);要分开控制就写成 timeout=(3.05, 27),含义是「连接最多等 3.05 秒,之后每次读数据最多等 27 秒」。官方文档里那个 3.05 是刻意略大于 3 的取值,因为它接近一次 TCP 重传的典型间隔,写整数反而容易和底层重传撞在一起。

换句话说,读取超时约束的是「相邻两块数据之间的间隔上限」,而不是整个响应的时长上限。

要特别留意的是默认行为:timeout 的默认值是 None,意思是无限等待。很多线上脚本卡死并不是超时太短被打断,而是压根没传这个参数,请求就一直挂着。这也是排查时第一个要确认的点——先看调用处到底传没传,再去调数值。

覆盖范围的边界可以这样记:DNS 解析、TCP 握手属于「连接到来之前」的等待,计入连接超时;而服务器开始返回之后,计时单位不是「整个响应」而是「相邻两个数据包之间的间隔」。加密握手的耗时归属在不同 urllib3 版本上有差异,涉及具体版本请以对应官方文档为准(编辑推断)。

想系统过一遍请求对象的完整参数与默认值,可以先看 Python requests 教程,再回来看边界差异会更省事。

二、为什么 timeout=5 还是会卡住:三个真实场景(失败与排错)

场景一,服务端慢速滴流。假设对方每 3 秒返回一个字节,你设置读取超时 5 秒,那么每次间隔都没超过读取超时,连接会一直保持到对方把数据发完。这在调用第三方导出接口、调用大模型流式返回时尤其常见:数据总量不大,但持续时间长,超时形同虚设。

场景二,流式分块读取。用 iter_content 或 iter_lines 逐块处理时,读取超时依然生效,但它是按「每次读一块」来算的,而不是按整个文件。一个 2GB 的附件,块大小为 8KB,理论上可以慢慢传好几个小时也不触发异常,而你的脚本还以为一切正常。

场景三,重试把总耗时放大。requests 本身不做业务层重试,很多人自己写了 while 循环,失败就重来一次,且中间没有退避。单次确实 5 秒就抛错了,但循环五次加上中间空等,最终观感就是「明明设了超时,却是很久之后才报错」。这三类的共同点是:单次都没有超时超限,超的是总时间。

三、最小复现:本地起一个慢速服务端(安装、配置、运行)

下面这一段服务端代码会先返回响应头,然后每 4 秒吐一个字节,用来重现「读超时永不触发」。保存为 slow_server.py:

import time
from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", "4")
        self.end_headers()
        for _ in range(4):
            self.wfile.write(b"a")
            self.wfile.flush()
            time.sleep(4)          # 模拟滴流式响应

    def log_message(self, *args):
        pass

if __name__ == "__main__":
    HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

启动服务端并另开一个终端跑客户端:

python slow_server.py

import time
import requests

start = time.time()
try:
    requests.get("http://127.0.0.1:8000/", timeout=5)   # 连接 5 秒,读 5 秒
except requests.exceptions.RequestException as e:
    print(type(e).__name__, "耗时", round(time.time() - start, 2), "秒")

预期结果(未在本机执行):连接很快建立,随后每次写入间隔 4 秒,小于读取超时,因此不会抛 ReadTimeout,进程会一直等到响应发送结束,累计耗时约 16 秒后才返回 200。把代码里的 sleep 改成 6 秒,间隔就超过了读取超时,同样的 timeout=5 会立刻抛出 ReadTimeout。这组对照能直接证明超时约束的是「相邻数据间隔」而不是总耗时。

语法细节拿不准时,翻一下 Python3 教程 里关于异常捕获和标准库的章节,比临时搜索更快。

四、三种补漏做法(介绍与对比)

方案 A:换一个有总时限的客户端。request 家族里没有「总超时」这个参数,要硬性封顶就得换 httpx 或者在 requests 外面套一层。httpx 支持 httpx.Timeout(total=20, connect=5, read=5),其中 total 就是整次请求的天花板。代价是要新增依赖,且部分老项目已经在用 Session 的钩子和适配器,替换成本不低。

方案 B:会话级重试与退避。把超时兜底交给「失败快速返回 + 有限次重试」,用 HTTPAdapter 挂载带退避的重试策略,让单次请求尽早失败,再由重试策略决定是否再来一次。这样总耗时是可计算的上界,而不是不可控的长尾。

方案 C:给 Session 注入默认超时。通过自定义适配器统一塞入超时值,避免每个调用点都手写一遍,也避免某处漏传导致无限等待。它不解决总时限问题,但能杜绝「忘记传参」这类低级故障。

方案 适用场景 优点 代价
A 换 httpx 新项目、能接受新依赖 真正的 total 上限 迁移 Session 生态的成本
B 会话重试 偶发抖动、调用第三方接口 总耗时可预估 需要挑白名单,避免放大写操作
C 注入默认超时 存量代码、多人协作 防止漏传参数 仍管不到总量,只防低级失误

连接超时、读取超时与总耗时的覆盖范围

下面这段是方案 B 与方案 C 的组合写法,重试只对幂等方法生效,并指定了退避系数:

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

retry = Retry(
    total=3,                       # 总重试次数上限
    connect=2,                     # 连接类错误单独计数
    read=2,
    backoff_factor=0.5,            # 退避:0.5s、1s、2s
    status_forcelist=[429, 500, 502, 503, 504],
    allowed_methods=["GET", "HEAD"],   # urllib3 2.x 用这个参数名
    raise_on_status=False,
)
s = requests.Session()
s.mount("https://", HTTPAdapter(max_retries=retry))
s.mount("http://", HTTPAdapter(max_retries=retry))

resp = s.get("https://example.com/api", timeout=(3.05, 10))
print(resp.status_code)

上面这段做的是「把重试次数与退避集中管理」。执行结果请根据网络环境自行核对(预期结果,未在本机执行)。注意 allowed_methods 在较早版本中叫 method_whitelist,升级或降级 urllib3 时报 AttributeError 基本就是这个原因;同理,只对 GET、HEAD 放行是出于安全考虑,POST 自动重试可能造成重复下单,这点在版本迭代时别忘了复核。

补齐了总时限之后,还有一个容易忽略的性能项:没有 Session 时每个请求都要重新握手,既慢又浪费连接。统一挂在 Session 上不仅能共享连接池,也是承载超时与重试策略的自然位置,这部分在 Python 速查手册 里查 Session 相关条目会更直观。

五、踩坑清单(失败、修复、验证与版本)

坑一,把数字写在了错误的位置。requests.get(url, 5) 里的第二个位置参数是 params,不是 timeout,写错之后请求一切正常但永远不超时;要用关键字 timeout=5 显式传。

坑二,混淆了 timeout=None 与 timeout=0。前者是无限等待,读取超时也一并失效;后者会把连接超时设成 0,通常立刻抛 ConnectTimeout,两者都不是「稍微等一会儿」的意思。

坑三,重试没有上限。即便单次很快失败,无退避的循环重试仍会让总耗时失控;修法是限定重试次数并加退避,验证方式是打日志记录 time.time() 的起止差值。

坑四,误以为超时能覆盖 DNS 或代理异常。代理连接不上时抛的是 ProxyError,且等待时间取决于代理本身的策略;正确的检查顺序是先看域名解析是否正常,再看代理环境变量,最后才回到超时参数。

坑五,在 Windows 上用信号做看门狗。signal.alarm 在 Windows 不可用,跨平台项目应改用线程池的超时等待或者换用支持总时限的客户端。

五类 timeout 踩坑的现象与修复

总结

requests 的超时管的是连接等待与两次数据之间的间隔,不管整次请求的总时长,这是「设了超时还在等」的根源。想要可控的总耗时,要么换用支持总时限的客户端,要么用带退避的会话级重试把失败收敛成可预估的上界,再配合统一注入的默认超时防止漏传参数。把这三层补齐,脚本就不会再出现既不报错也不退出的挂死现象。

常见问题

Q:timeout 传一个数字和传元组有什么区别?

传单个数字时连接与读取共用同一个值,等价于 (n, n);传元组 (connect, read) 才能分别控制,推荐连接值略大于 3 秒,读取超时按业务响应体大小来定。

Q:为什么设置了 timeout 却没有抛出任何异常?

大概率是服务端在持续返回数据,每次间隔都小于读取超时,此时属于「没超时」的正常情况;可以用第 3 节的慢速服务端复现这组对照。

Q:重试会不会把写操作重复执行?

会。默认应当只对 GET、HEAD 等幂等方法重试,涉及 POST 的请求要加业务幂等键,不能只依赖客户端重试策略。

延伸学习

0 人点赞