
装个 requests 而已能有多大事——这话我带新人时听过太多遍也亲眼见过太多翻车现场。有人pip install requests敲下去满屏红色报错有人装完import成功但第一个请求就卡在原地两分钟还有人本机跑得好好的换一台机器全部超时最后发现是某个依赖被别的包悄悄升了级。requests 这个库的特点就在这里它的 API 友好到让人误以为它没有坑可它背后挂着的链条其实很长——Python 解释器、pip 包管理器、虚拟环境、依赖树、证书库、字符编码、连接池、超时策略任何一环没对齐报错信息都会指向一个看起来跟它毫无关系的地方。这篇内容我想把 requests 的安装和测试一次性走完先讲清楚它到底替你做了什么、为什么要装那一串你从没主动用过的依赖再讲安装环节几种走法的取舍然后是装完之后怎么分层验证——不是import不报错就算完而是从包注册、版本自检、真实请求、字段核对一路验到边界行为最后把测试怎么写、坑怎么排讲透。适合刚上手的朋友照着抄也适合已经用了一两年但对内部机制还模糊的同学。1. requests 装进来的不只是一个库很多人对安装的认知停留在装一个包所以一看到pip list里多出来 urllib3、certifi、idna、charset_normalizer 这几个名字就犯迷糊以为是装错了。其实这正是理解 requests 的第一道门槛它不是一个自给自足的单体而是一层把底层能力重新组织的封装。搞明白这层关系后面百分之七八十的报错你都能一眼看出根源在哪。1.1 从 urllib 到 requests省掉的是哪几十行用 Python 标准库发一个带超时、带请求头、带异常处理的 GET 请求代码大概是这样import urllib.request import urllib.error req urllib.request.Request( https://example.com/data, headers{User-Agent: demo/1.0}, ) try: with urllib.request.urlopen(req, timeout5) as resp: status resp.status body resp.read() except urllib.error.HTTPError as e: status e.code body e.read() except urllib.error.URLError as e: status None body str(e.reason).encode()这段代码里塞了请求构造、头部设置、上下文管理、两类异常分支还是没处理重定向细节、没处理连接复用、没处理编码判断。requests 把这些全部收进了一行requests.get(url, timeout5)然后给你一个Response对象。省掉的不是几十行代码是每次发请求都要重新想一遍异常怎么分类的认知负担。这背后连接的是 urllib3真正管理 TCP 连接、连接池、重试、分块传输的是它requests 做的是 API 设计与行为决策。所以当你在 requests 里看到ConnectionError、ReadTimeout、ChunkedEncodingError这些名字它们其实都是从 urllib3 那一层透传上来的。1.2 Response 对象里最该先记住的六个字段新手拿到响应往往只看r.text然后一旦出现乱码或者解析失败就抓瞎。我的建议是装完的第一天就把这六个字段养成习惯字段含义什么时候必须看status_codeHTTP 状态码判断是否进入业务逻辑前headers响应头字典大小写不敏感排查内容类型、长度、缓存content原始字节串下载文件、图片、二进制text按某种编码解码后的字符串文本处理但要注意编码来源encodingrequests 推断/指定的解码方式出现乱码时的第一嫌疑elapsed从发出到响应读完的耗时判断慢在哪一环特别注意elapsed用的是timedelta要拿秒数得写r.elapsed.total_seconds()。很多人第一次用会直接拿它跟数字比较结果报TypeError白白浪费半小时。1.3 依赖树装 requests 实际上是装了一棵树执行pip install requests之后装进来的东西大致是这几类urllib3连接池、重试、TLS 会话的底层实现。certifi一份打包好的根证书集合requests 默认用它做证书校验。这是为什么你在某些机器上会遇到证书过期类报错——certifi 版本太老里面的根证书没跟上。idna处理国际化域名IDN的编解码。charset_normalizer负责在没有明确声明字符集时猜测编码新版本 requests 已经用它替换掉了原来的 chardet。理解这棵树的实际意义在于你遇到的绝大多数装不上跑不通根因都不在 requests 自己身上而在它的邻居身上。比如公司内网机器装了某个老版本的 urllib3新 requests 要求更高版本pip 就会在解析依赖时反复回溯甚至直接冲突退出比如 certifi 被别的东西锁死在很旧的版本你这边所有 HTTPS 请求都会报证书验证失败而错误信息里根本不会提 certifi 三个字。2. 安装这一步三种走法与它们各自的前提安装方式本身没什么神秘但选错场景会很麻烦。我把它分成三类直接装、隔离装、离线装。下面逐个说清楚适用条件和代价。2.1 直接全局安装快但只建议在一次性环境里用最省事的写法python -m pip install requests注意我用的是python -m pip而不是裸pip。这不是洁癖是有实际原因的一台机器上经常同时存在多个 Python系统自带的、包管理器装的、独立安装的裸pip到底指向哪个解释器取决于 PATH 的顺序很容易出现我明明装了怎么 import 不到。用python -m pip等于强制用当前这个解释器自己的包管理器去装把歧义消掉了。装完立刻回读一次python -m pip show requests python -m pip show urllib3 certifi idna charset-normalizerpip show会打印版本号、安装位置、依赖列表。养成这个动作的价值在于以后出问题时你能马上判断是不是装到了另一个解释器下面。如果遇到的是版本问题直接指定python -m pip install requests2.31.0给版本号加引号是给 shell 看的在某些 shell 里有特殊含义不加引号容易被截断这个坑在 Windows 的 cmd 和 PowerShell 上表现还不一样。2.2 虚拟环境不是最佳实践口号是排错成本的计算虚拟环境这件事被说得太规范了反而让人麻木。我换个角度讲假设你手上三个项目A 需要 urllib3 1.xB 需要 urllib3 2.xC 是老代码只能用 requests 2.25。全局装的话你每切一次项目就要重装一次依赖而每次重装都可能顺手把别的项目弄坏最后你会在某个深夜对着昨天还好好的抓狂。虚拟环境解决的就是这个每个项目一份独立的 site-packages互不干扰。标准流程# 创建 python -m venv .venv # 激活Linux / macOS source .venv/bin/activate # 激活Windows PowerShell .venv\Scripts\Activate.ps1 # 激活确认看输出前缀是否带 (.venv) python -c import sys; print(sys.prefix)Windows 上有一个高频卡点PowerShell 默认禁止执行脚本激活时会报无法加载文件因为在此系统上禁止运行脚本。这不代表虚拟环境坏了只是执行策略限制。可以在当前用户的范围内放开Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned至于用 venv 还是 conda我的判断标准很简单如果项目里只有 Python 依赖venv 足够且更轻如果还要装非 Python 的二进制依赖比如某些科学计算库需要的本地编译产物conda 省心。没必要为此争论选一个团队里大多数人在用的就行。2.3 指定源与离线安装两种非常现实的场景网络受限的环境下直连公共包索引经常很慢甚至超时。这时候换一个可达的镜像地址python -m pip install requests -i https://你的镜像地址/simple具体地址各家的不一样选一个你所在网络能访问到的即可。如果镜像使用自签证书可能还需要加--trusted-host 镜像主机名但这一步要谨慎它等于对这个主机放弃证书校验只应在你确认该主机可信时使用。另一种场景是完全没有外网。做法是在一台能联网的机器上把包和依赖下载成文件python -m pip download requests -d ./wheels然后把整个wheels目录拷到目标机器python -m pip install --no-index --find-links./wheels requests--no-index表示别去网上找--find-links表示就在这个目录里翻。这两个参数配合使用才算真正断网安装只写--find-links的话 pip 还是会尝试联网慢得让人怀疑人生。提示pip download默认只下载匹配当前平台的 wheel。如果目标机器是另一个平台或另一个 Python 小版本需要加--platform、--python-version、--only-binary:all:等参数否则拷过去会提示找不到匹配的发行版。3. 装完之后的分层验证从没报错到真的能用import requests不报错只证明包能被找到不证明它能在你的环境下完成一次请求。我把验证拆成四层逐层加深任何一层失败都能把问题范围缩小一大截。3.1 第一层包注册与版本落点python -m pip show requests python -m pip checkpip check会检查已安装包之间的依赖声明是否互相满足。它跑得快、输出直白是排查玄学问题的第一招。常见输出形如requests 2.31.0 has requirement urllib33,1.21.1, but you have urllib3 3.0.0.——这种情况在手动升级过某个子依赖之后非常容易出现。3.2 第二层导入、版本与关键子模块自检写一个单独的脚本别偷懒用一行命令因为一行命令在 Windows 的 cmd 里引号转义很容易出错import sys import ssl import requests import urllib3 import certifi print(python :, sys.version.split()[0]) print(requests :, requests.__version__) print(urllib3 :, urllib3.__version__) print(certifi :, certifi.__version__) print(certifi path:, certifi.where()) print(openssl :, ssl.OPENSSL_VERSION) print(default enc :, requests.utils.default_encoding)这一段输出信息量很大。certifi.where()告诉你证书集合的实际文件路径如果这个路径指向了一个不存在的文件那后面所有 HTTPS 请求必然失败ssl.OPENSSL_VERSION能帮你判断 TLS 能力某些老系统上的 OpenSSL 版本过旧连不上只支持新协议的站点而报错会伪装成连接被重置。把这些信息先存一份以后出问题可以直接对比好的时候是什么样。3.3 第三层一次真实请求逐字段核对挑一个确定可达的地址发一次请求并且把关键字段全打出来import requests resp requests.get( https://example.com, timeout(3, 5), headers{User-Agent: verify/1.0}, ) print(status :, resp.status_code) print(encoding :, resp.encoding) print(apparent :, resp.apparent_encoding) print(bytes :, len(resp.content)) print(chars :, len(resp.text)) print(ctype :, resp.headers.get(Content-Type)) print(elapsed :, round(resp.elapsed.total_seconds(), 3)) print(url :, resp.url) print(history :, [h.status_code for h in resp.history])timeout(3, 5)是元组形式分别代表连接超时和读取超时。这个细节值得专门记一下单个数字的timeout5在语义上是连接和读取各自最多 5 秒但实际使用中一次慢响应可能由连接慢和传输慢两部分叠加分开设置更容易定位问题。而且timeout是必须显式给的requests 的默认值是不超时意味着对方不返回你的进程就一直挂着。len(resp.content)和len(resp.text)的差值也很说明问题如果 content 是 12000 字节而 text 只有 4000 字符说明内容是 UTF-8 中文正常如果 text 长度明显偏大且出现大量Ã、å这种字符就是编码猜错了。3.4 第四层边界与异常行为验证这一层最容易被跳过但它恰恰决定了代码上线后的表现。至少要验这几件事404 不会抛异常requests 对 4xx/5xx 不抛异常只看status_code。想要抛就调resp.raise_for_status()。连接失败抛什么把地址改成http://127.0.0.1:1/一个几乎不可能开着的端口确认拿到的是requests.exceptions.ConnectionError。超时抛什么requests.exceptions.ConnectTimeout和ReadTimeout都继承自Timeout捕获时建议捕Timeout而不是捕Exception。JSON 解析失败抛什么resp.json()在内容不是合法 JSON 时抛requests.exceptions.JSONDecodeError新版或标准库的ValueError捕获时要留意版本差异。写成一个小脚本一次性跑完输出一张对照表以后换环境时重跑一遍就知道有没有退化。我在几个项目里都保留着这样一个check_env.py它比任何文档都可靠因为它测的是现在这一台。4. 测试怎么写别让单元测试真的去敲外网给 requests 相关代码写测试最大的误区是测试里也发真请求。这么干的直接后果是网络一抖测试就红第三方接口一改字段测试就红CI 上跑一次要几分钟。测试的价值在于快速给出确定的答案而真实网络恰恰是最不确定的东西。4.1 打桩的基本思路替换的是传输层不是你的代码好在 requests 的设计对打桩很友好因为它所有请求最终都会经过Session.send或适配器层第三方库只要在这里拦一刀就行。最常用的是responses用装饰器或者上下文管理器把指定的 URL 注册成固定响应import responses import requests responses.activate def test_fetch_user_ok(): responses.add( responses.GET, https://api.example.test/users/1, json{id: 1, name: A同学}, status200, ) resp requests.get(https://api.example.test/users/1, timeout2) assert resp.status_code 200 assert resp.json()[name] A同学 assert len(responses.calls) 1 assert responses.calls[0].request.headers[User-Agent].startswith(python-requests)注意这里我用的域名是api.example.test.test是保留后缀永远不会解析到真实主机。这个习惯值得养成测试里的地址一律用保留域名或127.0.0.1既能防止误请求线上服务也避免有人把测试代码里的地址当成真实接口去调用。responses.activate的行为是把所有没注册过的请求直接判失败。这个默认策略我强烈建议保留因为它能抓到你以为打桩了其实没打上的情况——比如代码里拼出来的 URL 多了一个斜杠、或者查询参数顺序不同导致匹配不上。4.2 需要测真实连接行为时起一个本地服务有些东西打桩测不了连接超时、读取超时、分块传输、重定向次数。这些必须有一个真实的 socket 才能验证。最简单的方式是用标准库起一个本地 HTTP 服务import json import threading import time from http.server import BaseHTTPRequestHandler, HTTPServer class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path /slow: time.sleep(2) if self.path /redirect: self.send_response(302) self.send_header(Location, /ok) self.end_headers() return body json.dumps({ok: True, path: self.path}).encode(utf-8) self.send_response(200) self.send_header(Content-Type, application/json; charsetutf-8) self.send_header(Content-Length, str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass def start_server(): server HTTPServer((127.0.0.1, 0), Handler) port server.server_address[1] t threading.Thread(targetserver.serve_forever, daemonTrue) t.start() return server, port绑定的端口写0表示让系统分配一个空闲端口避免测试之间抢端口。log_message覆写成空函数否则每次请求都会往 stderr 打一行日志测试输出会变得很难看。有了这个服务超时就能真测了import pytest import requests def test_read_timeout(): server, port start_server() try: with pytest.raises(requests.exceptions.ReadTimeout): requests.get(fhttp://127.0.0.1:{port}/slow, timeout(1, 1)) finally: server.shutdown()这里超时设成 1 秒服务端睡 2 秒必然触发读取超时。要注意finally里必须调用shutdown()否则serve_forever的线程会一直占着测试进程退不出去。4.3 超时与重试这部分测试最容易被写成测了个寂寞重试逻辑如果用HTTPAdapterRetry实现测试的关键是让服务端在前几次返回 5xx然后验证客户端最终拿到 200并且总请求次数符合预期。from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(): session requests.Session() retry Retry( total3, backoff_factor0.2, status_forcelist[500, 502, 503, 504], allowed_methods[GET], raise_on_statusFalse, ) adapter HTTPAdapter(max_retriesretry, pool_connections10, pool_maxsize10) session.mount(http://, adapter) session.mount(https://, adapter) return session几个参数值得解释一下因为写错任何一个测试就会变成看起来在测重试其实一次都没重试total3限制的是重试次数不含首次请求所以最多发出 4 次。backoff_factor0.2会让退避时间按 0.2、0.4、0.8 递增含随机抖动别设成 0否则失败瞬间连打三次等于给对方添堵。allowed_methods一定要显式给。默认只对幂等方法重试是相对安全的但历史上某些版本的默认行为变过显式声明最保险。POST 默认不重试是有道理的——重发可能造成重复下单之类的后果。raise_on_statusFalse让最终失败时把响应原样返回由你的业务代码决定怎么处理而不是直接抛异常。还有一个常被忽略的点Retry只作用于连接层重试和状态码重试它不管你业务上的接口返回 200 但 body 里code ! 0。后者得自己在业务层写循环两者不要混为一谈。5. 装完才暴露的五类问题与它们的真正成因这一节我按症状—根因—动作的顺序写都是我在实际项目里反复遇到的。你会发现几乎每一条的第一反应都是错的。5.1 SSL 证书报错先别急着关校验典型报错是SSLCertVerificationError: certificate verify failed: unable to get local issuer certificate。很多人的第一反应是加verifyFalse然后就地把警告压掉。这非常危险它等于放弃了身份确认而且很多人还会顺手写进生产代码。正确的排查顺序是跑一遍certifi.where()确认文件存在且可读。检查 certifi 版本太旧的根证书集合可能已经不包含目标站点使用的根。如果是企业内网很可能是流量在网关处被解密重签此时应该把网关的根证书加入信任链而不是关校验——REQUESTS_CA_BUNDLE环境变量或verify/path/to/bundle.pem都行。检查系统时间。这个听起来离谱但确实常见容器里时间漂移几分钟证书的有效期判断就会失败。注意verifyFalse在测试代码里用来隔离环境问题尚可但必须配上urllib3.disable_warnings()之外的显式注释说明原因并且绝不允许进入生产配置。5.2 乱码三种来源三种解法乱码的根因永远只有一个——编码和解码用了不同的字符集。但来源分三种响应头没声明 charset。这时 requests 会退回到猜。稳妥做法是显式指定resp.encoding resp.apparent_encoding或者干脆resp.content.decode(utf-8)。响应头声明的 charset 是错的。某些老旧服务端不管内容是什么都声明ISO-8859-1此时apparent_encoding会给出更靠谱的猜测。请求体编码不对。用data传 dict 时 requests 会做表单编码用json传 dict 时会序列化成 JSON 并带上charset相关的头。如果你手动传了一个已经编码过的bytes又没设Content-Type服务端可能按别的字符集解析这就是我发出去的是中文对方收到的是问号的由来。5.3 连接池默认值并发一上来就体现Session内部维护连接池默认每个 host 最多保留 10 个连接。单线程脚本完全够用但一旦上多线程就会出现明明只发了 20 个请求怎么有一半在等的现象——因为池满了多出来的线程在排队。表现是整体耗时被拉长而不是报错所以很难第一眼看出来。adapter HTTPAdapter(pool_connections20, pool_maxsize50, pool_blockFalse)pool_connections是保留的 host 池数量pool_maxsize是单个池的最大连接数。pool_blockFalse表示池满时不阻塞而是新建连接用完丢弃代价是连接复用率下降。这两个值调大要配合服务端的承受能力盲目设成几百只会把人家的限流触发得更快。5.4 依赖冲突pip check 是唯一靠谱的裁判昨天还能跑这类问题的头号原因就是某个包在装别的东西时把共享依赖升了级。典型链条你装了某个库它依赖 urllib32pip 顺手把 urllib3 升了而你的老代码里用了 urllib3 1.x 才有的接口于是运行时报AttributeError: module urllib3 has no attribute ...。处理办法是固化成文件python -m pip freeze requirements.txt以后重建环境一律python -m pip install -r requirements.txtfreeze会把当前所有包的精确版本都写进去包括间接依赖代价是文件很长、升级时要手动改。另一个做法是只锁顶层依赖的版本区间让 pip 自己解析间接依赖代价是可复现性差一些。我的习惯是应用项目用freeze全锁库项目用区间约束。无论哪种pip check都应该进 CI。5.5 重定向与 TooManyRedirectsrequests 默认跟随重定向最多 30 次超过就抛TooManyRedirects。这个异常在正常业务里很少见但有两种情况会撞上一是登录态失效后站点在几个页面间反复跳形成环二是某些接口把 302 用得不当跳转到自己。排查时把allow_redirectsFalse打开看resp.status_code和resp.headers[Location]一跳一跳手动跟很快就能找到环在哪。还有一个容易忽略的点跨域重定向时requests 默认会把Authorization头去掉这是安全设计但如果你依赖这个头做鉴权重定向之后就会收到 401而错误信息不会告诉你头被删了。6. 一条可复现的排查链路从一句报错走到根因前面讲的是分类这一节讲流程。因为真实场景里你拿到的往往只有一行报错需要自己一步步收窄。6.1 四步定位法先分域再细分我习惯按这个顺序走每一步都能把范围砍掉一半第一步分清是装的问题还是跑的问题。判据很简单新建一个空目录建全新虚拟环境只装 requests跑一个最小脚本。如果这一步就失败问题在环境如果成功问题在你的项目依赖。第二步分清是连不上还是连上了但结果不对。前者抛ConnectionError/Timeout后者能拿到status_code。这两类问题的排查方向完全不同前者看网络与 DNS后者看请求构造与响应解析。第三步分清是本机特有问题还是普遍问题。换一台机器、换一个网络跑同样的脚本。如果只有某台机器出问题重点看那台机器的证书库、时间、环境变量如果都出问题重点看代码。第四步把不确定的东西全打出来。请求前打url、headers、params请求后打status_code、headers、前 200 字节的content。很多人凭想象猜服务端返回了什么一打印发现根本不是那回事。6.2 三个现场还原现场一ModuleNotFoundError: No module named requests但pip list里明明有。根因几乎总是解释器不一致。编辑器里配的解释器是 A终端里pip装到了 B。动作在报错的那个进程里打印sys.executable然后对这个路径执行-m pip install。现场二脚本在本机秒回在服务器上卡 60 秒后ConnectTimeout。大概率是出网策略限制。动作先用curl -v或ping验证目标可达性注意 ICMP 通不代表 TCP 端口通再看requests抛的是连接超时还是读取超时——连接超时说明握手就没成读取超时说明连上了但对方不回。现场三json.decoder.JSONDecodeError但抓包看响应体明明是 JSON。常见于响应被中间层加了 BOM 或者前后多了空白还有一种情况是服务端返回了压缩内容而中间某处已经解压过一次。动作先打印resp.content[:200]看原始字节再打印resp.headers.get(Content-Encoding)确认压缩声明最后用resp.json()之前先resp.content.decode(utf-8-sig)试试。6.3 最小可复现脚本模板排查到最后一定要能产出一个别人拿去就能跑出同样结果的脚本。我平时用的模板长这样import json import sys import traceback import requests def probe(url: str, **kwargs) - None: print( * 60) print(python :, sys.version.split()[0]) print(requests:, requests.__version__) print(url :, url) try: resp requests.get(url, timeout(3, 10), **kwargs) print(status :, resp.status_code) print(elapsed :, round(resp.elapsed.total_seconds(), 3)) print(history :, [h.status_code for h in resp.history]) print(headers :, json.dumps(dict(resp.headers), ensure_asciiFalse)) print(body[:200]:, repr(resp.content[:200])) except Exception: traceback.print_exc() if __name__ __main__: probe(https://example.com/)这个模板的价值不在代码本身在于它把所有可能影响结果的因素都固化成了可打印的输出解释器版本、库版本、请求地址、耗时、重定向链、响应头、原始字节。把它贴进工单对方不用问你环境是什么就能复现。7. 几个我踩过之后才记住的细节timeout不写不是用默认值而是永不超时。这个默认值坑过太多人尤其在批处理脚本里一个卡住的请求能让整个任务停到天亮。我现在写任何请求代码第一个敲的参数就是timeout。用Session而不是裸requests.get。裸调用每次都新建连接一次请求一次 TCP 握手加 TLS 握手批量请求时开销非常可观。改成with requests.Session() as s:之后同一 host 的连接会被复用实测在几十次请求的量级上能省掉一半以上的时间。而且Session可以统一挂 headers、挂适配器配置集中在一处比在每个调用点重复传参可靠得多。resp.json()不要裸调。它内部会先判断内容是否为空空内容会抛异常而不是返回None。线上接口返回 204 或者网关返回空 body 时这一行就会炸。稳妥写法是先判if resp.content:再解析或者包一层 try。测试里永远不要相信这个接口不会变。我见过太多测试用例直接对着真实地址发请求第一次跑通之后再也没跑过等 CI 上重新启用时全线飘红。花二十分钟用responses打桩能省掉后面几十次的误报排查。最后分享一个小习惯在项目根目录放一个scripts/check_requests.py内容就是第 3.2 节和 3.3 节那两个自检脚本的合并版只做打印不做断言。每次换机器、换虚拟环境、升级依赖之后跑一遍把输出跟上次的存一份对比。它不会告诉你哪里错了但它能让你在十分钟内知道变了什么——而绝大多数排查的时间其实都花在确认到底哪里不一样上。