ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python Requests库深度解析:从默认行为到生产级HTTP客户端实践

Python Requests库深度解析:从默认行为到生产级HTTP客户端实践

1. 从一行代码说起:为什么每个开发者都绕不开 Requests

如果你写过Python,大概率写过这样一行代码:requests.get("https://example.com")。它简洁到几乎不像在写代码,更像在说一句人话。但就是这行看似平平无奇的调用,背后藏着一整套HTTP协议栈的封装哲学。我见过不少朋友,用Requests用了两三年,遇到超时、重定向、编码乱码、连接池耗尽这些问题时,依然是一头雾水,只能靠搜索引擎东拼西凑。问题不在于他们笨,而在于从一开始就没人告诉他们:Requests到底替你做了什么,以及它在哪些地方悄悄埋了坑。

这篇文章想做的事情很纯粹——把Requests这个库从“会用”讲到“用明白”。我会从它的设计理念切入,拆解它最核心的几个对象模型,然后逐个击破实际项目中最常遇到的场景:会话保持、超时控制、重试策略、文件上传下载、代理配置、SSL验证、编码处理。每一个点我都会给出可直接复制的代码,同时解释清楚“为什么这样写”而不是“只能这样写”。适合已经能跑通基本请求、但在复杂场景下总觉得心里没底的开发者,也适合想系统梳理HTTP客户端知识的人。

先给一个结论:Requests不是银弹,它是一个“默认行为合理、但需要你主动干预”的工具。理解它的默认值,比记住它的API更重要。

2. Requests的设计哲学:为什么它比urllib好用那么多

2.1 从urllib的繁琐说起

Python标准库里的urllib能发HTTP请求,但用起来实在折磨人。你要手动构建Request对象,手动处理URL编码,手动读取响应,手动解码字节流。一个最简单的GET请求,代码量是Requests的三倍以上,而且可读性极差。更别提处理重定向、Cookie、SSL证书这些常见需求时,urllib的API设计会让你怀疑人生。

Requests的作者Kenneth Reitz在2011年左右开始这个项目,核心目标只有一个:让HTTP请求变得人性化。他做的最关键决策是——把HTTP协议中那些繁琐但必要的细节,全部封装成合理的默认行为。比如自动跟随重定向、自动解压gzip、自动猜测编码、自动管理连接池。你不需要知道这些机制存在,它们就在后台默默工作。

但这种“自动”也带来一个问题:当默认行为不符合你的需求时,你往往不知道去哪里改。这就是为什么很多人用Requests遇到问题时,第一反应是“换个库”而不是“改个参数”。

2.2 核心对象模型:Request、Response、Session

Requests的API表面上看是一堆函数,但底层其实只有三个核心对象在流转。

Request对象是你构造的请求意图。当你调用requests.get(url)时,内部会先创建一个Request对象,把URL、方法、头部、参数等信息塞进去。这个对象是“未发送”的状态,你可以把它理解成一封写好的信,还没投递。

Response对象是服务器返回的结果。它包含了状态码、响应头、响应体、Cookie、重定向历史等所有信息。Response对象是“已接收”的状态,信已经拆开了。

Session对象是前两者的容器和协调者。每次调用requests.get(),其实都隐式创建了一个临时的Session,用完就销毁。这意味着每次请求都是全新的连接、全新的Cookie、全新的头部。如果你需要跨请求保持某些状态,就必须显式使用Session。

import requests # 隐式Session:每次都是新的 r1 = requests.get("https://httpbin.org/cookies/set?name=value") r2 = requests.get("https://httpbin.org/cookies") print(r2.text) # Cookie不会保留 # 显式Session:状态保持 s = requests.Session() s.get("https://httpbin.org/cookies/set?name=value") r = s.get("https://httpbin.org/cookies") print(r.text) # Cookie会保留

这个区别在实际项目中极其关键。我见过有人用requests.get()循环调用接口,每次都重新建立TCP连接,性能差得离谱,还以为是服务器慢。换成Session之后,QPS直接翻倍。

2.3 默认行为清单:你需要知道的“自动挡”

Requests的默认行为很多,我挑几个最容易踩坑的列出来:

默认行为具体表现可能的问题
自动跟随重定向301/302/303/307/308都会自动跳转无法获取中间响应,POST可能被改成GET
自动解压gzip/deflate/br自动解压响应体是解压后的,Content-Length对不上
自动猜测编码优先用响应头,其次用chardet中文乱码的常见根源
连接池复用同一Session内复用TCP连接连接池大小默认10,高并发会排队
不验证超时默认无限等待程序可能永久挂起
验证SSL证书默认开启自签名证书会报错

这些默认值在简单场景下都是合理的,但在生产环境中,你几乎必须逐个调整。下一节我会详细讲怎么调。

3. 会话与连接池:被低估的性能杀手

3.1 Session不只是Cookie容器

很多人以为Session就是用来保持登录状态的,这个理解太窄了。Session真正的价值在于连接池复用。HTTP/1.1默认开启Keep-Alive,一个TCP连接可以承载多次请求。如果你每次都用requests.get(),每次都会新建连接、三次握手、慢启动,然后关闭。在高频调用场景下,这部分的耗时可能占总耗时的30%以上。

用Session之后,底层会维护一个连接池。默认情况下,HTTPAdapter的pool_connections=10,pool_maxsize=10。意思是最多缓存10个连接,同时最多使用10个连接。如果你的并发量超过10,多出来的请求会等待,而不是报错。这个等待时间可能很长,导致你以为程序卡死了。

from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() adapter = HTTPAdapter( pool_connections=50, # 连接池缓存数量 pool_maxsize=50, # 最大并发连接数 max_retries=Retry(total=3, backoff_factor=0.5) ) session.mount("https://", adapter) session.mount("http://", adapter)

这段配置是我在大多数生产项目中的起点。pool_maxsize要根据你的并发线程数来定,一般建议等于或略大于并发数。max_retries配合backoff_factor可以实现指数退避重试,避免雪崩。

3.2 连接池耗尽的排查思路

连接池耗尽的表现是:程序在某个点之后突然变慢,日志显示请求耗时飙升,但服务器端看起来一切正常。这时候你可以检查几个地方:

  • 是否每个请求都创建了新Session?如果是,连接池根本没起作用。
  • pool_maxsize是否小于并发线程数?如果是,多出的线程在排队。
  • 是否有请求没有正确关闭?虽然Requests会自动释放,但如果响应体没读完就丢弃,连接可能无法复用。

提示:用session.close()显式关闭Session是个好习惯,尤其是在长时间运行的服务中。但更关键的是确保每个响应都被完整读取或显式关闭。

3.3 Session的线程安全性

这里有一个容易被忽略的点:Session对象不是线程安全的。如果你在多线程环境中共享一个Session,可能会出现连接错乱、Cookie覆盖等问题。正确的做法是每个线程用自己的Session,或者用线程局部存储(threading.local)来管理。

import threading local = threading.local() def get_session(): if not hasattr(local, "session"): local.session = requests.Session() # 配置adapter... return local.session

这个模式我在多个爬虫和API客户端项目中使用过,稳定可靠。代价是每个线程一个连接池,内存占用会高一些,但换来了线程安全。

4. 超时、重试与异常:让程序不再“假死”

4.1 超时不是可选项,是必选项

Requests默认没有超时。这意味着如果服务器不响应,你的程序会永远等下去。这在脚本里可能只是卡住,在服务里就是线程泄漏、连接耗尽、最终崩溃。所以每一个请求都必须设置超时,没有例外。

超时分为两种:connect超时和read超时。connect是建立TCP连接的时间,read是等待服务器返回第一个字节的时间。你可以分别设置,也可以用一个值统一设置。

# 统一设置:连接和读取都是5秒 requests.get(url, timeout=5) # 分别设置:连接3秒,读取10秒 requests.get(url, timeout=(3, 10))

我的经验值是:内网服务连接超时1-2秒,读取超时5-10秒;公网服务连接超时3-5秒,读取超时15-30秒。具体要根据业务容忍度调整。但无论如何,不要用timeout=None。

4.2 重试策略:不是所有失败都值得重试

重试能提高成功率,但盲目重试会放大故障。你需要区分哪些错误可以重试:

  • 连接超时:可以重试,通常是网络抖动。
  • 读取超时:谨慎重试,可能是服务器处理慢,重试会加重负担。
  • 连接错误:可以重试,可能是临时DNS问题。
  • 4xx错误:不要重试,客户端问题重试没用。
  • 5xx错误:可以重试,但要有上限和退避。

urllib3的Retry类提供了精细控制:

from urllib3.util.retry import Retry retry_strategy = Retry( total=3, # 总重试次数 status_forcelist=[500, 502, 503, 504], # 这些状态码触发重试 allowed_methods=["GET", "POST"], # 只对这些方法重试 backoff_factor=1, # 退避因子:1, 2, 4秒 raise_on_status=False # 重试耗尽后不抛异常 )

backoff_factor=1意味着第一次重试等1秒,第二次等2秒,第三次等4秒。这个指数退避能有效缓解服务器压力。注意allowed_methods默认只包含幂等方法,POST需要显式加上,但你要确保接口是幂等的,否则可能重复下单。

4.3 异常处理:捕获什么,不捕获什么

Requests的异常体系不算复杂,但容易混淆:

  • requests.exceptions.Timeout:超时,包括连接和读取。
  • requests.exceptions.ConnectionError:连接失败,DNS、拒绝连接等。
  • requests.exceptions.HTTPError:raise_for_status()抛出的4xx/5xx。
  • requests.exceptions.RequestException:所有异常的基类。

我的建议是:捕获RequestException作为兜底,但针对Timeout和ConnectionError做单独处理。因为这两类的恢复策略不同。HTTPError通常不需要捕获,除非你要根据状态码做业务逻辑。

try: r = session.get(url, timeout=(3, 10)) r.raise_for_status() except requests.exceptions.Timeout: # 记录日志,可能重试或降级 pass except requests.exceptions.ConnectionError: # 记录日志,检查网络或DNS pass except requests.exceptions.RequestException as e: # 兜底,记录完整异常 pass

注意:raise_for_status()只对4xx和5xx抛异常,3xx不会。如果你关闭了自动重定向,需要自己处理3xx。

5. 请求与响应的精细控制:头部、参数、编码、文件

5.1 头部设置:User-Agent只是开始

设置请求头是常见需求,但有几个细节容易出错。首先,头部键名大小写不敏感,但Requests会保留你传入的大小写。其次,某些头部有特殊处理,比如Host、Content-Length、Transfer-Encoding,一般不要手动设置。

headers = { "User-Agent": "MyApp/1.0", "Accept": "application/json", "Authorization": "Bearer token123" } r = requests.get(url, headers=headers)

如果你用Session,可以在Session级别设置默认头部,这样每个请求都会带上:

session.headers.update({"User-Agent": "MyApp/1.0"})

但要注意,Session的头部会与请求级头部合并,请求级的优先。这个机制在需要覆盖某个头部时很有用。

5.2 URL参数与表单数据:params vs data vs json

这三个参数经常让人困惑,我用一个表格说清楚:

参数用途Content-Type编码方式
paramsURL查询字符串无URL编码
data表单提交application/x-www-form-urlencodedURL编码
jsonJSON提交application/jsonJSON序列化
# GET带查询参数 requests.get(url, params={"q": "python", "page": 1}) # 实际URL: url?q=python&page=1 # POST表单 requests.post(url, data={"name": "张三", "age": 30}) # POST JSON requests.post(url, json={"name": "张三", "age": 30})

一个常见的坑是:用data传字典时,如果值包含中文,Requests会自动URL编码,但服务器端解码方式可能不同,导致乱码。用json参数则不会有这个问题,因为JSON默认UTF-8。

5.3 响应编码:乱码的根源与解法

中文乱码是Requests最常见的问题之一。根源在于:HTTP响应头中的Content-Type可能没有指定charset,或者指定了错误的charset。Requests的默认策略是:先看响应头,如果没有则用chardet猜测。但chardet的准确率不是100%,尤其是短文本。

r = requests.get(url) print(r.encoding) # 查看当前使用的编码 print(r.apparent_encoding) # chardet猜测的编码 # 手动指定编码 r.encoding = "utf-8" print(r.text)

我的建议是:如果明确知道服务器返回UTF-8,直接设置r.encoding = "utf-8"。不要依赖自动猜测。如果服务器返回GBK,设置r.encoding = "gbk"。对于JSON接口,用r.json()通常没问题,因为JSON规范要求UTF-8。

5.4 文件上传与下载:流式处理大文件

上传文件用files参数:

files = {"file": ("report.pdf", open("report.pdf", "rb"), "application/pdf")} requests.post(url, files=files)

下载大文件时,不要用r.content一次性读入内存,而是用stream=True配合iter_content:

r = requests.get(url, stream=True) with open("large_file.zip", "wb") as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk)

chunk_size建议8KB到64KB,太小会增加循环次数,太大占用内存。stream=True还能让你在下载前检查状态码和头部,避免下载一个404页面。

提示:用stream=True时,必须确保响应被完全读取或显式调用r.close(),否则连接不会释放回连接池。

6. 代理、SSL与认证:企业环境下的必备技能

6.1 代理配置:HTTP和HTTPS分开设置

在企业内网中,通过代理访问外部服务是常态。Requests的代理配置很简单:

proxies = { "http": "http://proxy.example.com:8080", "https": "http://proxy.example.com:8080" } requests.get(url, proxies=proxies)

注意https的代理地址通常也是http://开头,因为代理协议本身是HTTP。如果代理需要认证,可以写在URL里:http://user:pass@proxy.example.com:8080。但这样会把密码暴露在代码中,更好的做法是用环境变量HTTP_PROXY和HTTPS_PROXY。

6.2 SSL证书验证:什么时候该关,什么时候不该关

Requests默认验证SSL证书。如果服务器用自签名证书,会抛出SSLError。很多人图省事直接verify=False,但这会带来中间人攻击风险。正确的做法是:把自签名证书下载下来,用verify参数指定证书路径。

requests.get(url, verify="/path/to/ca_bundle.pem")

如果实在无法获取证书,至少要在关闭验证的同时禁用警告:

import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) requests.get(url, verify=False)

但我要强调:verify=False只应该出现在测试环境或完全可信的内网中。生产环境关闭证书验证,等于把安全大门敞开。

6.3 认证方式:Basic、Digest、OAuth

Requests内置了Basic和Digest认证:

from requests.auth import HTTPBasicAuth, HTTPDigestAuth requests.get(url, auth=HTTPBasicAuth("user", "pass")) requests.get(url, auth=HTTPDigestAuth("user", "pass"))

OAuth通常需要手动设置Authorization头部,或者用requests-oauthlib这样的扩展库。对于Bearer Token,直接设置头部即可:

headers = {"Authorization": f"Bearer {token}"} requests.get(url, headers=headers)

7. 实战中的坑与应对:来自项目的经验

7.1 重定向导致的POST变GET

HTTP规范规定,301和302重定向时,浏览器和客户端通常会把POST改成GET。Requests也遵循这个行为。如果你在做一个POST请求,服务器返回302,Requests会自动用GET请求重定向后的URL,原始的表单数据就丢了。解决方案是关闭自动重定向,手动处理:

r = requests.post(url, data=data, allow_redirects=False) if r.status_code == 302: # 手动决定是否用POST重新提交到新URL new_url = r.headers["Location"] r = requests.post(new_url, data=data)

7.2 响应体过大导致内存溢出

默认情况下,Requests会把整个响应体读入内存。如果下载一个几GB的文件,内存直接爆掉。用stream=True可以解决,但要注意:即使stream=True,如果你访问了r.content或r.text,依然会全部读入。所以要么用iter_content,要么确保不碰这两个属性。

7.3 连接池与多线程的配合

前面提到Session不是线程安全的。但如果你用requests.get(),每次都是新Session,连接池无法复用。折中方案是用requests.Session()配合线程局部存储,或者用连接池库如requests_toolbelt的PoolManager。我在高并发爬虫中通常用线程局部Session,每个线程维护自己的连接池,实测QPS比每次新建连接高3-5倍。

7.4 超时设置过短导致的误判

超时设置太短,会把正常的慢请求误判为失败,触发不必要的重试。我见过有人把读取超时设成1秒,结果服务器稍微慢一点就重试,重试又加重服务器负担,形成恶性循环。建议先用一个较宽松的超时观察一段时间,再根据P99耗时来设定。

8. 从Requests出发:什么时候该换工具

Requests适合大多数同步HTTP场景,但它不是万能的。如果你需要异步高并发,aiohttp或httpx的异步模式更合适。如果你需要HTTP/2支持,httpx是更好的选择。如果你需要更底层的控制,urllib3直接操作连接池可能更灵活。

但无论如何,理解Requests的设计和默认行为,是掌握这些工具的基础。因为它们的API设计很大程度上都受到了Requests的影响。把Requests用透,再学其他库,你会发现很多概念是相通的。

我在实际项目中的体会是:Requests的简单是表象,它的复杂性藏在默认值里。每次遇到奇怪的问题,先问自己“Requests默认做了什么”,往往就能找到答案。比如乱码先看编码,超时先看是否设置了timeout,连接慢先看是否用了Session。这些经验比记住API更有价值。

最后分享一个小技巧:用r.request.headers可以查看实际发送的请求头,用r.history可以查看重定向历史,用r.elapsed可以查看请求耗时。这三个属性在调试时非常有用,能帮你快速定位问题出在客户端还是服务端。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进