requests 库的安装和测试,看起来是两条命令就能收工的活儿,但我带过的几批新人里,真正能把这两件事做扎实的不到三成。装上了不等于装对了,跑通了不等于测得准——前者关乎环境隔离和依赖版本,后者关乎你能不能在没有真实接口的情况下稳定复现问题。这篇内容会把 requests 库从安装到测试的完整链路拆开讲,包括虚拟环境怎么选、依赖装了什么、超时和重试参数怎么算出来、测试用例怎么在离线状态下断言。适合刚接触 HTTP 客户端编程的新手,也适合已经写了几百行爬取或接口调用代码、但总觉得心里没底的中级开发者。读完你至少能拿到两样东西:一套可复现的安装流程,一套能长期留在项目里的测试骨架。
1. 先把账算清楚:这一步为什么值得单独讲
1.1 从标准库手搓到一次调用,省下的到底是什么
很多人觉得 requests 无非就是给标准库套了层壳,没什么好讲的。但我实际对比过同一件事在两种写法下的代码量:拼一个带查询参数和请求头的 GET 请求,标准库那边要 urllib.parse.urlencode 拼参数、urllib.request.Request 建对象、再套 urlopen、再处理异常分支,二十行上下;requests 这边一行 requests.get(url, params=..., headers=..., timeout=...)。
省下的不只是行数。真正省下的是三件容易出错的事:第一,连接和流的关闭由上下文管理器兜底,不用担心忘了 close 导致句柄泄漏;第二,编码判断、gzip 解压、重定向跟随这些“脏活”内置在库里,不用每次自己写;第三,异常体系是分层的,连接超时、读取超时、状态码异常能分开捕获,排查时一眼就能定位到是网络层还是业务层的问题。
所以安装这一步不只是“把包装上”。你要装的是一个会替你处理这些细节的运行时依赖,它的版本、它依赖的底层库版本,都会直接影响后面排查问题时的判断依据。
1.2 安装环节最容易翻车的三个地方
我自己和身边同行踩过的坑,高度集中在三类。
第一类是装到了错误的解释器里。机器上装了系统自带的 Python、又装了包管理器装的 Python、还有 IDE 里配的另一个解释器,pip install装完,IDE 里import requests依然报 ModuleNotFoundError。这种问题排查起来特别费时间,因为报错信息完全不指向真实原因。
第二类是依赖版本互相打架。requests 本身不算重量级,但它依赖 urllib3、certifi、idna、charset_normalizer 这几块,其中 urllib3 的大版本变化会影响重试和连接池的配置写法,certifi 的更新节奏会影响某些站点的证书校验结果。项目里如果还有别的库对 urllib3 有版本约束,很容易装出一个“能 import 但行为诡异”的组合。
第三类是以为装完就万事大吉,从没验证过。我见过有人把库装上,代码写了三个月,直到某天需要下载一个大文件,才发现自己一直用的是默认无超时的请求方式,一个卡住的连接能把整个进程吊死。安装后的第一件事应该是做一次带超时的连通性验证,而不是直接开写业务代码。
1.3 本文的环境基线与验证目标
为了让后面的步骤可以直接对照,先说清楚基线:Python 3.9 及以上,pip 版本 21 以上(低版本对 wheel 的支持不够好,装纯 Python 包没问题,但涉及二进制依赖时容易退化成源码编译)。requests 用当前主流的 2.3x 系列。
验证目标定四条,装完之后逐条过一遍:能否在隔离环境里成功导入并打印版本号;能否发出一个带超时参数的 GET 请求并拿到 200;能否正确处理一次非 2xx 响应而不抛未捕获异常;能否在断网环境下用测试替身跑通单元测试。这四条过了,安装和测试这两件事就算闭环了。
2. 安装实操:十分钟从空环境到第一个请求
2.1 环境隔离:虚拟环境到底该不该用
我的答案是必须用,理由不是“规范要求”,而是成本核算。创建虚拟环境大概十秒钟,而排查一次版本冲突至少要半小时起步,这笔账不需要犹豫。
创建和激活的命令按平台分:
# 创建,项目根目录下执行 python -m venv .venv # Linux / macOS 激活 source .venv/bin/activate # Windows PowerShell 激活 .venv\Scripts\Activate.ps1 # Windows CMD 激活 .venv\Scripts\activate.bat激活成功的标志是命令行提示符前面出现(.venv)。这里有个细节很多人忽略:激活之后,python和pip指向的必须是同一个环境。验证方法是分别跑python -c "import sys; print(sys.executable)"和pip -V,输出里的路径应该落在同一个.venv目录下。如果不一致,说明 PATH 里有别的可执行文件插队了,这时候最稳的做法是统一用python -m pip的写法,绕过 PATH 查找直接调用当前解释器的模块。
注意:在 Windows 上第一次执行激活脚本时,如果提示脚本执行策略被限制,不要去改全局策略,改用
cmd里的.bat脚本激活即可,避免给整台机器留下宽松的执行策略。
2.2 三种安装路径与依赖树解读
路径一:在线安装。最常规的做法。
python -m pip install requests如果你所在的网络环境访问公共包索引比较慢,可以临时指定一个速度更快的镜像源,写法是-i加索引地址,这只影响本次安装,不改变全局配置:
python -m pip install requests -i https://<你的镜像源地址>/simple想长期生效就用pip config set global.index-url <地址>,配置会写进用户级配置文件。我个人的习惯是不改全局配置,只在需要时加-i,因为不同项目对源的要求不一样,全局配置容易在换环境时忘了这回事。
路径二:指定版本安装。项目要交付或者要复现别人的问题时,版本必须锁死。
python -m pip install "requests==2.31.0"引号不能省,尤其在 shell 里,否则==可能被解释成别的东西。
路径三:离线安装。内网机器或者无外网环境的标准做法是先在能联网的机器上把 wheel 包下下来:
# 联网机器上执行,把包和依赖一起下载到 wheels 目录 python -m pip download requests -d ./wheels # 目标机器上执行,只从本地目录安装,不走网络 python -m pip install --no-index --find-links=./wheels requests装完之后看一眼依赖树,这一步很多人跳过,但它决定了你后面排查问题时的思路:
python -m pip show requests python -m pip listpip show会输出 requests 自身的信息和它声明的依赖项。实际安装中你会看到 urllib3、certifi、idna、charset_normalizer 这几个同伴一起进来。理解它们各自管什么很重要:urllib3 负责底层的连接池和重试机制,你在 requests 层面配的 Retry 最终是喂给它的;certifi 提供 CA 证书包,某些站点的 SSL 校验失败往往和它有关;charset_normalizer 负责在响应头没给编码时猜测编码。知道这些,遇到 SSL 报错或者乱码时就知道该往哪个方向查,而不是盲目重装。
2.3 装完必须做的三行验证
装完立刻验证,别拖到写业务代码的时候。
import requests print(requests.__version__) resp = requests.get("https://example.com", timeout=(3.05, 10)) print(resp.status_code, len(resp.content))第一行确认能导入,第二行确认能真正发出请求。这里特意给了元组形式的超时参数,因为它同时约束了建连和读取两个阶段,比单个数字更精确,具体怎么取值在第 3 节展开。
如果这一步就失败了,先别急着怀疑库,按顺序排查:python -c "import sys; print(sys.path)"看模块搜索路径对不对;python -m pip -V看 pip 归属;python -c "import ssl; print(ssl.OPENSSL_VERSION)"看 SSL 后端是否正常。这三条基本能把环境问题和网络问题区分开。
2.4 安装阶段报错速查
| 报错片段 | 真实原因 | 处理方式 |
|---|---|---|
ModuleNotFoundError: No module named 'requests' | 装到了另一个解释器 | 用python -m pip install重装,装完核对sys.executable |
Could not find a version that satisfies | 索引源不可达或包名拼错 | 换索引源,确认包名大小写 |
SSLError: certificate verify failed | 证书包过旧或系统时间偏差 | 升级 certifi,校准系统时间 |
PermissionError写入 site-packages 失败 | 装到了系统级目录且无写权限 | 改用虚拟环境,不要加 sudo 硬装 |
装完 import 报ImportError: cannot import name ... | 同名文件遮蔽,比如本地有个requests.py | 改名,清掉__pycache__ |
最后一条特别隐蔽。我自己就干过这种事:在项目根目录建了个requests.py当临时脚本,结果整个项目的 requests 导入全部指向这个文件,报错信息指向库内部,完全看不出是命名冲突。养成不给临时文件起依赖同名名字的习惯,能省下不少时间。
3. 核心参数怎么定:超时、重试、连接池的取舍逻辑
3.1 Session 复用与直接调用的分界线
requests.get()每次调用都会新建一个 Session,用完销毁。这在单次请求、脚本工具里没问题,但在循环里调几十上百次就是灾难——每次都重新建 TCP 连接、重新做 TLS 握手,耗时能差出好几倍。
判断标准很简单:同一目标域名下要发多次请求,就用 Session。
import requests session = requests.Session() session.headers.update({"User-Agent": "my-client/1.0"}) try: for url in urls: resp = session.get(url, timeout=(3.05, 10)) # 处理响应 finally: session.close()注意finally里的 close。虽然 Session 有析构兜底,但显式关闭能保证连接池里的连接及时归还,在长驻进程里这个差别很明显。另外session.headers.update()设置的是会话级默认头,后续每个请求都会带上,这比每次调用都传一遍 headers 干净得多。
还有一个容易忽略的点:Session 会默认保留 Cookie。如果你的场景是反复调用同一个接口做压测式验证,服务端下发的会话 Cookie 会被自动带上,可能让你误以为接口行为变了。需要无状态调用时,用session.cookies.clear()或者在创建时把 Cookie 策略关掉。
3.2 超时参数的取值依据与计算
不设超时是最危险的写法,没有之一。默认情况下 requests 会一直等下去,一个半死不活的连接能把整个进程吊住,而日志里什么都看不到。
超时参数支持单个数字或元组。单个数字同时作用于建连和读取两个阶段,粒度太粗。元组写法是(connect_timeout, read_timeout),我推荐一律用元组。
取值不能拍脑袋,按这个思路算:
- 建连超时:同机房内网调用,1 秒足够;跨公网调用,取 3 到 5 秒。业界流传的 3.05 这个小数,来源是某些系统对 3 秒整数边界有特殊处理,取个零头规避边界效应,实际用 3 也行。
- 读取超时:这是等首字节到达的时间,不是整个请求的总时长。正确算法是拿到目标接口的响应时间分布,取其 P99 再乘 2 到 3 倍作为上限。比如你统计到接口 P99 是 300 毫秒,那读取超时设 1 秒就够;如果接口本身要做复杂查询,P99 到 2 秒,那设 5 到 6 秒比较稳妥。
这里有个必须纠正的认知偏差:timeout管的是单次 socket 读操作的等待时间,不是整个请求的生命周期。也就是说,只要服务端每隔一小段时间吐一点数据,请求总时长可以远超 timeout 值而不会触发超时。如果你需要一个“总时长上限”,得自己用time.monotonic()记录起点,在读取循环里判断,或者用并发超时控制。
提示:流式下载场景下更不能只靠 timeout。
stream=True配合iter_content时,每次读 chunk 都会重置计时,一个持续慢速输出的服务端能让你的请求挂很久。
3.3 重试策略的退避时间与陷阱
重试不是“失败了再来一次”这么简单,配错了会把一次小故障放大成雪崩。
标准写法是挂一个 HTTPAdapter:
from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry = Retry( total=3, connect=3, read=2, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=frozenset(["GET", "HEAD"]), raise_on_status=False, ) adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter) session.mount("http://", adapter)退避时间的实际算法要讲清楚,不然你会对日志里为什么是这个间隔感到困惑。urllib3 的公式是backoff_factor × 2^(重试序号 - 1),第一次重试的等待时间是 0,第二次是0.5 × 2^0 = 0.5秒,第三次是0.5 × 2^1 = 1秒。也就是说 backoff_factor=0.5、total=3 的情况下,总等待约 1.5 秒,加上三次请求本身的耗时。如果你想让它更“缓”,把 factor 提到 1 或 2,但要算清楚总耗时会不会超过上游给你的调用时限。
几个必须绕开的坑:
- 只对幂等方法重试。
allowed_methods一定要显式指定。默认值在旧版本里是全方法重试,意味着一个已经到达服务端的 POST 会被重复提交,产生重复订单、重复扣款这类事故。 - 不要对 4xx 无脑重试。400、401、403、404 重试多少次结果都一样,只会浪费时间配额。
status_forcelist只放 429 和 5xx 这类可能瞬时恢复的状态码。 raise_on_status=False的作用是把状态码判断交回业务层。设成 True 时,重试耗尽后会抛RetryError,异常类型和你平时捕获的HTTPError不一样,容易漏捕。- 重试次数和总超时要做乘法。total=3 意味着最坏情况下耗时是单次超时的 3 倍还要加上退避时间。如果下游给你的整体时限是 10 秒,而单次读取超时设了 5 秒,那重试 3 次必然超时。这种情况应该压单次超时,而不是砍重试次数。
3.4 连接池容量的估算方法
连接池的默认值是pool_connections=10, pool_maxsize=10,前者是缓存的连接池数量(按 host 区分),后者是单个池子的最大连接数。
估算方法:并发线程数不能超过 pool_maxsize,否则多出来的线程会排队等连接,表现为“请求发出去了但迟迟不返回”,日志里看不到任何错误,特别难查。
举例,如果你用 20 个线程跑并发,那 pool_maxsize 至少设 20,通常留点余量设成 25。设置方法:
adapter = HTTPAdapter( pool_connections=10, pool_maxsize=25, max_retries=retry, pool_block=False, )pool_block的含义值得单说:设成 False 时,池子满了会临时新建连接,用完后丢弃,不报错;设成 True 时会阻塞等待,直到有连接归还。前者对突发流量更友好但有连接数失控风险,后者更可控但需要确认不会死锁。我的习惯是在有并发上限控制的场景里用 False,在没有并发控制、完全靠池子兜底的场景里用 True。
4. 数据处理的细节:编码、流式下载与请求体构造
4.1 响应编码的三个判据与选择顺序
乱码是 requests 使用中最高频的问题,根源在于编码判断逻辑。响应对象上有三个相关属性,理解它们的来源才能选对:
| 属性 | 来源 | 可靠性 |
|---|---|---|
resp.encoding | 优先取响应头Content-Type里的 charset,没有则按 RFC 默认给 ISO-8859-1 | 头部缺失时完全错误 |
resp.apparent_encoding | 用 charset_normalizer 对内容做统计猜测 | 中等,短文本容易误判 |
resp.content | 原始字节,未经解码 | 最可靠 |
推荐的读取顺序是:先看响应头有没有明确 charset,有就直接resp.text;没有的话,短响应(几 KB 以内)用apparent_encoding还能接受,长响应或者结构化数据(JSON、XML)直接拿resp.content自己解码,别信猜测。
resp = requests.get(url, timeout=(3.05, 10)) if "charset" in resp.headers.get("Content-Type", "").lower(): text = resp.text else: text = resp.content.decode("utf-8", errors="replace")这里errors="replace"是兜底,遇到非法字节用替换字符顶上,避免整个请求因为一个坏字节崩掉。代价是数据有损,所以生产代码里建议把解码异常单独记一条日志,方便回溯是哪批数据有问题。
JSON 响应还有一个独立的坑:resp.json()内部走的是resp.text,也就是说它同样受编码判断影响。如果服务端返回的 Content-Type 没写 charset,而内容里又有中文,resp.json()可能直接抛解码异常。稳妥写法是先resp.content再json.loads(),绕开这层不确定性。
4.2 流式下载与完整性校验
默认情况下 requests 会把整个响应体读进内存。下载一个几百兆的文件时,内存占用会直接飙上去。正确姿势是stream=True配合分块写入:
with requests.get(url, stream=True, timeout=(3.05, 30)) as resp: resp.raise_for_status() total = int(resp.headers.get("Content-Length", 0)) written = 0 with open("target.bin", "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if not chunk: continue f.write(chunk) written += len(chunk) if total and written != total: raise IOError(f"大小不符,期望 {total},实得 {written}")几个细节值得展开。
chunk_size=8192是经验值,对应常见的 8KB 磁盘块和网络缓冲大小。设得太小(比如 1)会导致大量系统调用,速度断崖式下降;设得太大(比如 1MB)在慢速网络下会让单次读取阻塞更久,超时判断变迟钝。
Content-Length校验是必要但不充分的。它能发现传输中断导致的截断,但如果服务端启用了分块传输编码(Transfer-Encoding: chunked),响应头里根本没有 Content-Length,这时候total是 0,校验逻辑要跳过而不是判定失败。更严格的校验是下载完成后比对哈希值,前提是服务端提供了哈希。
with语句包住响应对象,保证异常时连接也能正确释放。这一点在流式场景下尤其重要,因为响应体没读完就中断时,连接不能被复用,必须显式关闭,否则池子里的可用连接会越来越少,最终表现为请求全部卡住。
4.3 表单、JSON、文件上传的写法差异
三种请求体的构造方式完全不同,混用会直接导致服务端收不到数据。
表单提交用data,内容会被编码成application/x-www-form-urlencoded:
resp = session.post(url, data={"key": "value", "count": 3}, timeout=(3.05, 10))注意值必须是字符串或数字,传嵌套结构会被展开成奇怪的形式。要传列表就用data=[("tag", "a"), ("tag", "b")]这种元组列表,能得到tag=a&tag=b。
JSON 提交用json,库会自动序列化并设置正确的 Content-Type:
resp = session.post(url, json={"name": "测试", "tags": ["a", "b"]}, timeout=(3.05, 10))这里有个高频错误:手动headers={"Content-Type": "application/json"}的同时又把序列化后的字符串塞进data。这么写能用,但一旦忘了序列化(直接传 dict 给 data),服务端收到的就是 Python 的字典表示,报错信息还特别难懂。用json=参数能彻底避免这个类别的问题。
文件上传用files,格式是字段名映射到文件对象或元组:
files = {"upload": ("report.csv", open("report.csv", "rb"), "text/csv")} resp = session.post(url, files=files, data={"scene": "batch"}, timeout=(3.05, 60))元组的三段分别是文件名、文件对象、MIME 类型。不给 MIME 类型时库会按扩展名猜,猜错了某些服务端会拒收。上传的超时值要单独放宽,因为大文件传输耗时和响应耗时不是一个量级。
注意:用文件对象时记得自己关。
files参数不会替你关闭传入的句柄,长期运行的服务里这是典型的资源泄漏点。用with open(...) as f:包起来最稳。
5. 测试体系:把“能跑通”变成“可回归”
5.1 测试分层的四个层次
写 requests 相关的测试,最怕两种情况:一是全依赖真实接口,接口一挂测试全红,跑一次要几分钟;二是全靠手写 mock,mock 和真实行为偏差越来越大,测试全绿但线上照崩。
我的做法是分四层,各管一摊:
第一层是冒烟测试,跑真实网络,只验证一件事:能不能连上目标、能不能拿到 2xx。这一层用例极少,通常就一两个,跑得慢也无所谓,放在发布前手动触发。
第二层是契约测试,用录制好的响应样本(真实抓包保存下来的 body 和 header)喂给代码,验证解析逻辑。这一层跑得飞快,可以每次提交都跑。
第三层是异常路径测试,验证超时、429、5xx、连接重置这些情况的处理是否正确。这一层靠 mock 制造异常,是真实接口最难复现的部分。
第四层是并发与资源测试,验证连接池容量、连接归还、文件句柄是否泄漏。这一层用多线程跑,观察进程的连接数和内存曲线。
四层的价值不一样:第一层保证方向没错,第二三层保证逻辑没错,第四层保证长期跑不出事。实际项目里我见过只做第一层的,也见过只做第二层的,都不够。
5.2 用响应替身做离线断言
离线测试的核心思路是拦截底层请求,返回预先准备好的响应。手写 mock 的写法是打补丁:
from unittest.mock import patch, Mock def test_parse_user(): fake = Mock() fake.status_code = 200 fake.headers = {"Content-Type": "application/json; charset=utf-8"} fake.content = b'{"id": 1, "name": "\\u6d4b\\u8bd5"}' fake.json.return_value = {"id": 1, "name": "测试"} with patch("requests.Session.get", return_value=fake): result = fetch_user(1) assert result["name"] == "测试"这种写法的局限很明显:fake.content和fake.json()得自己保证一致,忘了就埋雷;raise_for_status()的行为也得手动模拟。所以更适合用专门的响应替身库(比如 responses),它按 URL 匹配注册响应,行为和真实请求更接近:
import responses @responses.activate def test_fetch_user_ok(): responses.add( responses.GET, "https://api.example.invalid/users/1", json={"id": 1, "name": "测试"}, status=200, ) result = fetch_user(1) assert result["name"] == "测试" assert responses.calls[0].request.headers["Accept"] == "application/json" @responses.activate def test_fetch_user_server_error(): responses.add( responses.GET, "https://api.example.invalid/users/1", status=503, ) with pytest.raises(ServiceUnavailable): fetch_user(1)@responses.activate装饰器保证测试结束后自动注销注册的响应,不会污染其他用例。末尾那句responses.calls[0].request...是这套工具的真正价值:不只断言返回值,还能断言发出去的请求长什么样,包括头、查询参数、请求体。很多线上问题就出在“请求发出去的形式不对”,这部分断言能提前拦住。
需要注意,测试里用的域名必须是不会真实解析的(比如.invalid后缀),万一装饰器没生效,请求会直接失败而不是打到真实服务上,避免测试污染生产数据。
5.3 让超时和重试变得可测
超时和重试是最难测的部分,因为你没法真的等十秒。两个技巧。
技巧一:把时间参数抽成可注入的配置。不要在函数体里写死timeout=10,而是从配置对象读。测试时传一个极小的值,就能在几十毫秒内跑到超时分支。
@dataclass class ClientConfig: connect_timeout: float = 3.05 read_timeout: float = 10.0 max_retries: int = 3技巧二:用响应替身的副作用模拟“先失败后成功”。responses 支持为同一个 URL 注册多个响应,按顺序消费:
@responses.activate def test_retry_then_success(): responses.add(responses.GET, "https://api.example.invalid/data", status=503) responses.add(responses.GET, "https://api.example.invalid/data", status=503) responses.add(responses.GET, "https://api.example.invalid/data", json={"ok": True}) result = fetch_with_retry() assert result == {"ok": True} assert len(responses.calls) == 3这里要注意重试逻辑的实现位置。如果你用的是 HTTPAdapter 层的自动重试,responses 拦截的是 requests 层,重试发生在更底层,可能导致断言次数对不上。这种情况要么把重试逻辑上移到业务层显式实现,要么在测试里换成更底层的拦截方式。这也是我倾向在业务层写显式重试循环的原因之一:可测性更好,日志也更清晰。
技巧三:给超时测试留一个显式的失败断言。光验证“超时后能继续”不够,还要验证“超时确实触发了”。可以用一个计数变量或者检查日志条数,确认走了超时分支,而不是碰巧走了别的路径。
5.4 一份可以直接抄的测试用例清单
| 用例 | 构造方式 | 断言重点 |
|---|---|---|
| 正常 200 解析 | 替身返回标准 JSON | 字段值、类型、编码 |
| 头部缺 charset 的中文 | 替身返回 UTF-8 字节但不声明 | 解码正确,无乱码 |
| 404 | 替身返回空体 | 抛业务异常,不抛裸 HTTPError |
| 429 后恢复 | 连续两次 429 再一次 200 | 重试次数、最终结果 |
| 连接超时 | 注入极小超时 + 延迟响应 | 异常类型、耗时上限 |
| 响应体截断 | Content-Length 声明大于实际 | 完整性校验触发 |
| 大文件下载 | 分块返回 1MB 数据 | 内存峰值、文件大小 |
| 并发连接池 | 多线程同时请求 | 无排队超时、连接数不超上限 |
表格里最后两条在普通单测里跑代价大,可以放到单独的标记组里,用pytest -m slow控制是否执行。日常提交跑前六条,发布前跑全量。
6. 排查实录:我在真实项目里踩过的坑
6.1 报错速查表
| 报错 | 常见根因 | 排查动作 |
|---|---|---|
ConnectionError | 目标不可达、DNS 失败、端口不通 | 先用ping/telnet验证网络层,再怀疑代码 |
ReadTimeout | 服务端处理慢或网络抖动 | 看服务端日志的时间戳,确认是不是真的慢 |
SSLError | 证书过期、链不完整、系统时间错 | 检查 certifi 版本和系统时间 |
ChunkedEncodingError | 服务端提前断开或返回不完整分块 | 重试一次,稳定复现则联系服务端 |
TooManyRedirects | 重定向循环 | 手动关掉跟随,逐跳观察 Location |
| 内存持续上涨 | 未关闭的响应、未流式处理大文件 | 检查是否漏了with或close() |
| 请求全部变慢但无报错 | 连接池耗尽在排队 | 打印session.adapters的池状态,调大 maxsize |
6.2 三个典型场景的排查路径
场景一:循环里跑得好好的,加线程后全部变慢。
这个我遇到过两次,第一次排查了很久。现象是所有请求都变慢,但没有任何报错,超时也没触发。原因是连接池默认 10,起了 30 个线程,20 个在排队等连接,而排队时间不计入 timeout。定位方法是打日志记录每个请求从发起到返回的耗时,看是否是整齐的阶梯状(说明在排队)。修复就是把 pool_maxsize 提到并发数以上。
场景二:本地测什么都对,线上偶发中文乱码。
根因是服务端在不同节点上的 Content-Type 头不一致,有的带 charset 有的不带,而不带的时候resp.text按 ISO-8859-1 解码,中文全废。这类问题没法通过单个用例覆盖,得在解析入口统一改成按resp.content解码并记录实际使用的编码,跑一段时间看日志里出现几种编码。
场景三:一个后台任务跑几小时后连接数爆掉。
典型原因是流式下载时异常退出了,响应体没读完,连接没归还。配合重试机制,每次失败都会留下一个占着不放的连接。修复方式是把下载逻辑整体包进with,并且在校验失败时显式resp.close()。这个坑的教训是:任何手动控制读取节奏的地方,都要配套一个无论走哪条路径都会执行的清理动作。
6.3 上线前我会过的六项检查
- 所有
requests.get/post调用都带timeout,用全局搜索确认没有遗漏。 - 循环里的请求都走了 Session,不在循环里新建。
- 重试的
allowed_methods显式指定,且只包含幂等方法。 - 大文件路径使用了
stream=True,且配套完整性校验。 - 响应解码不依赖
resp.text的默认行为,显式处理编码。 - 测试里没有指向真实环境的 URL,替身域名用
.invalid后缀。
这六项里前三项是硬性要求,后三项是长期稳定运行的保障。我个人的体会是,第 2 项和第 5 项最容易被忽略,因为它们在功能测试里完全看不出来问题,只在并发量上去或者遇到特定服务端时才暴露。与其等出了事故再回头补,不如在写第一行请求代码时就按这个清单过一遍,成本几乎为零。