requests这个库,玩Python的几乎没人能绕开。以前我用标准库urllib写请求,代码又臭又长,重定向要手动处理,Cookie要自己维护,遇到HTTPS证书报错还得写一堆异常分支——直到换了requests,第一次觉得“发个HTTP请求”这件事可以这么清爽。这个库说白了就是帮开发者把HTTP协议底层那堆细节封装成几句直白的函数调用,无论是对接第三方API、写爬虫脚本还是做接口自动化测试,它都是绕不开的基础工具。
这篇文章我不打算照着官方文档念一遍,而是把我这几年实际用它做项目踩过的坑、验证过的写法、还有那些文档里不会写明白的边界情况,一次性整理出来。适合已经会写基础Python、正准备上手网络请求的读者,也适合已经用过requests但想系统补齐细节的开发者。从环境搭建讲到会话管理,再到文件上传下载、错误处理、性能调优,最后附一份高频问题速查表——你可以照着目录跳到想看的部分。
1. 项目全貌与核心价值拆解
1.1 它解决的痛点:从urllib到requests的进化
Python标准库其实自带urllib和urllib3,但直接用起来是真的别扭。urllib.request.urlopen拿一个响应要手动处理编码,URL参数拼接时遇到中文就得先quote转义,Cookie要自己搞一个opener才能管理,重定向默认行为还得额外判断……这些琐碎操作每一个单独看都不难,但组合在一起就形成了很高的认知负担。
requests做的最关键的一件事,是把HTTP请求过程中那些“重复但容易出错”的操作全部标准化了。GET、POST、PUT、DELETE都变成了同名方法,query string可以用字典传参,JSON数据可以直接传dict然后自动序列化,HTTPS证书校验默认开启但也能一键关闭,重定向自动跟随,Cookie在Session内自动持久化。开发者只需要关心业务层面的数据,而不是每次都在底层协议细节里打转。
我经常用一个类比来说明requests的价值:它就像点外卖,你只需要告诉平台要什么菜、送到哪、口味偏好,剩下比价、接单、配送、签收这些流程平台全包了。urllib则像是你自己骑电动车去店里取,覆盖面广但每一步都要亲力亲为。
1.2 设计理念里的底层逻辑
requests的底层依赖是urllib3,它自己则是一层更友好的封装。这个设计很有意思——urllib3负责连接池管理、重试机制、线程安全这些偏底层的基建工作,requests则专注于把API做得人性化。所以你在requests里看到的很多参数,比如timeout、verify、allow_redirects,最终都会被翻译成底层的连接行为。
理解这个分层结构很重要。当你遇到性能瓶颈时,很多优化手段其实是在调整底层的连接池参数,而不是requests本身;当你需要细粒度控制重试策略时,通常要自己构建HTTPAdapter。这些后面都会展开讲。
还有一点值得注意:requests是同步阻塞的库,发一个请求就得等响应。它不适合高并发场景——如果你需要同时发出几百上千个请求,应该考虑aiohttp或者httpx的异步模式。但正因为同步,它的代码逻辑非常直观,调试定位也容易,绝大多数业务场景(比如调用接口、中小规模爬虫、自动化测试脚本)它反而是最稳的选择。
2. 环境准备与最基础的使用方式
2.1 安装与环境验证
requests的安装本身没什么可说的,一条命令的事。我在新环境里通常会顺便把版本确认一下,因为不同版本的默认行为差异不小,尤其是证书校验和编码处理这块。
pip install requests python -c "import requests; print(requests.__version__)"如果公司内网走代理,安装时可能需要额外指定--proxy参数,这点在自动化部署脚本里记得留个备用方案。我遇到过好几次明明代码没问题,结果环境里没装requests导致整个任务失败的场景,所以强烈建议在项目依赖文件里显式声明版本号(比如requests==2.31.0),避免哪天云端的默认版本跳变后出现兼容性意外。
2.2 三分钟跑通第一个GET请求
new一个请求、拿响应、读数据,这套流程大概是所有Python开发者最先接触的三行代码。核心就是把URL、查询参数、请求头都放在get()的参数里,然后把响应对象里的内容字段取出来用。
import requests # 显式指定请求头 headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36" } resp = requests.get("https://api.example.com/search", params={"q": "requests库"}, headers=headers, timeout=10) print(resp.status_code) print(resp.url) # 实际请求的完整URL,包含拼好的查询参数 print(resp.text[:500]) # 文本内容响应对象resp里的字段需要区分清楚:.status_code是HTTP状态码,.text是解码后的字符串,.content是原始字节流,.json()是对JSON格式响应的快捷解析。新手最容易搞混的是.text和.content,如果响应里有特殊编码的中文,直接用.text可能出现乱码,这时候要靠.content配合指定编码来还原。
2.3 参数传递的三种姿势,以及各自的使用边界
很多初学者传参数时喜欢直接在URL字符串里拼,比如"https://api.example.com/search?q=xxx&page=1"。短时期没问题,但一旦参数多了、有中文或者特殊字符,手拼字符串很容易翻车,要么忘记转义,要么顺序错了导致签名校验不过。requests给了三种规范姿势,我用表格整理一下它们的适用场景。
| 传参方式 | 代码写法 | 适用场景 | 注意事项 |
|---|---|---|---|
| 查询参数 | params={"q": "关键词", "page": 2} | GET请求的URL query string | 字典里的值会自动做URL编码 |
| 表单参数 | data={"username": "admin", "password": "123456"} | POST登录、表单提交 | 默认编码为application/x-www-form-urlencoded |
| JSON参数 | json={"name": "测试", "age": 20} | 对接RESTful API、JSON接口 | requests自动设置Content-Type: application/json |
这里有个细节值得多说一句:json=参数和data=参数不能混用。如果你同时写了json和data,requests会优先发送data中的内容,而json里的数据会被忽略——这个行为在文档里有写,但实际很多人踩过。我自己的习惯是:接口文档明确说接收JSON就统一用json=,接收表单就统一用data=,绝不混着传,这样出了问题也容易排查。
响应解析上,.json()看着方便,但实际项目里最常见的报错就是它引发的。一旦服务端返回的不是合法JSON(比如返回了错误页面或纯文本提示),.json()会直接抛出requests.exceptions.JSONDecodeError。稳妥的做法是先用resp.status_code判断状态,再判断resp.headers.get("Content-Type")里有没有application/json,最后才尝试.json()。这些细节后面在错误处理章节会再展开。
3. 请求定制:Header、鉴权、会话与Cookie
3.1 Header定制里最容易踩的坑
服务器判断客户端身份和意图,主要就是看请求头。最常见的是User-Agent和Referer——前者告诉服务器“我是谁”,后者告诉服务器“我从哪来”。很多接口对这两个字段有隐性校验,尤其是一些做了基础反爬策略的站点,没有合理的UA可能直接被拒。
headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...", "Referer": "https://www.example.com/", "Accept-Language": "zh-CN,zh;q=0.9", "Accept": "application/json", }关于Content-Type需要多说一嘴:这个请求头用来声明发送内容的媒体类型,不同传参方式对应不同取值。如果你手动设置了一个Content-Type: application/json,但实际用data=传的是普通表单结构,服务端解析时就可能直接失败。所以我的建议是:能交给json=或data=自动处理的场景,就别手动干预这个头;只有做文件上传这类特殊需求时才需要手动指定multipart/form-data——而实际上,requests的files=参数也会自动帮你设置好。
3.2 Session会话:Cookie自动管理的正确姿势
写爬虫或者对接需要登录的接口时,一个最常见的错误是:每次都直接调用requests.get()或requests.post()。这样每次请求都是独立的,上一个请求种下的Cookie到下一个请求就没了,服务端根本不知道你是同一回访者。要维持登录状态,必须用Session对象。
s = requests.Session() s.headers.update({"User-Agent": "..."}) # 先登录 login_data = {"username": "user01", "password": "pass123"} s.post("https://api.example.com/login", data=login_data, timeout=10) # 后面所有请求自动携带登录态 s.get("https://api.example.com/profile", timeout=10)Session的原理其实很简单:它内部维护了一个CookieJar,自动处理服务端通过Set-Cookie发来的Cookie。后续同一Session发出的请求会自动带上这些Cookie,行为表现跟浏览器开了一个标签页保持一致。
这里补充一个工程上的心得:Session对象内部复用TCP连接,频繁请求时能省去握手开销,速度有明显提升。Session不是线程安全的,多个线程共享同一个Session容易出问题。如果你要写多线程爬虫,有两条路——每条线程创建独立的Session,或者用requests.Session搭配锁来控制并发。经验上来说,前者更简单可控。
3.3 超时、重试与SSL证书处理:三大保命技能
超时。许多生产事故的根源就是没设超时。默认行为下如果不设置timeout,请求可能一直挂在那里,线程被白白占住。这个参数不是等响应总时长的上限,而是“连接建立”和“服务器数据到达间隔”各自的等待上限,分元组传可以更精细化控制。
# 连接等待最多3秒,数据到达间隔最多5秒 resp = requests.get(url, timeout=(3, 5))重试。requests本身没有内置重试机制,要用HTTPAdapter来挂载Retry策略。这里我直接给出一个可复用的写法:
from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry = Retry( total=3, # 最多重试3次 connect=3, # 连接失败重试3次 read=2, # 读取失败重试2次 status=2, # 状态码触发重试最多2次 backoff_factor=0.5, # 指数退避:0.5, 1, 2, 4... status_forcelist=[500, 502, 503, 504] # 这些状态码触发重试 ) adapter = HTTPAdapter(max_retries=retry) s = requests.Session() s.mount("http://", adapter) s.mount("https://", adapter)backoff_factor是重试间隔的倍率,第一次重试等待0.5秒,第二次1秒,第三次2秒,依此类推。这样设计的原因是要给服务端恢复的时间,同时不要一股脑把请求全部打上去造成二次雪崩。注意Retry类来自urllib3而不是requests,这个很多人第一次找会懵。
SSL证书。开发调试时最头疼的就是自签名证书,默认校验会直接抛SSLError。两种解决思路:一是用verify=False关闭校验,二是指定CA证书路径verify="/path/to/cert.pem"。生产环境不建议关闭校验,但内网接口如果全是自签证书,手动维护证书链又太痛苦——这时候我的做法是:代码里做成配置项,默认开启校验,只有内网环境才允许通过环境变量关闭,并打日志提醒。
verify_mode = False if os.getenv("ENV") == "dev" else True resp = requests.get(url, verify=verify_mode, timeout=10)4. 文件上传下载与流式请求的完整实践
4.1 文件上传:multipart/form-data的组装细节
对接文件上传接口时,核心是files参数。它接收一个字典,key对应表单字段名,value是文件对象或元组。requests会把字典拼成multipart/form-data的格式,同时自动生成边界标记。
files = { "file": ("report.pdf", open("report.pdf", "rb"), "application/pdf"), } resp = requests.post("https://api.example.com/upload", files=files, timeout=60)这里有个三个元素的元组需要注意:分别是文件名、文件对象、Content-Type。如果你不指定Content-Type,requests会默认用application/octet-stream——有些服务端对这个字段有严格校验,比如后端按image/png分类处理图片,不写对就会走错逻辑。
既有文件又有其他表单字段时,把字段放data参数就行:
resp = requests.post( "https://api.example.com/upload", data={"description": "季度报告"}, files=files, timeout=60, )注意:data和files同时使用没问题,这点和json混用的情况不一样。日常排查上传问题时,三个方向先查:文件路径拿到没、Content-Type对不对、文件名是否包含特殊字符(有些后端对文件名做了黑名单过滤)。
4.2 大文件下载与流式读取的正确打开方式
下载大文件直接用resp.content一次性读进内存,内存占用会直接爆掉。正确的姿势是开启stream=True,然后按块写入本地文件。
resp = requests.get("https://example.com/bigfile.zip", stream=True, timeout=30) with open("bigfile.zip", "wb") as f: for chunk in resp.iter_content(chunk_size=8192): f.write(chunk)iter_content按指定字节数迭代内容块,8192字节是常见选择,也能根据文件类型调整。流式模式下响应不会立即把全量内容加载进内存,而是等迭代时才逐块读取。
文件下载经常遇到中断问题,我有一次下载一个将近5GB的模型权重,网线稍微一动就断了,前面的进度全废。补上断点续传思路:先记录已下载大小,下次请求时通过Range头续传,Range: bytes=1024-表示从1024字节开始拿数据。有些CDN服务端不认这个头,但大部分主流站点都支持。
headers = {"Range": "bytes=" + str(existing_size) + "-"} resp = requests.get(url, headers=headers, stream=True, timeout=30) with open(filepath, "ab") as f: for chunk in resp.iter_content(chunk_size=8192): f.write(chunk)加上这个逻辑后,下载脚本的健壮性能提升几个量级。注意,开启stream=True时务必用with包裹响应对象,或者手动调用resp.close(),否则底层连接不会释放,请求多了会把手持连接数耗尽。
5. 错误处理与异常排查体系
5.1 HTTP状态码与异常的分工协作
requests把异常统一放在requests.exceptions里,最基础的规则是:网络层面的问题(连不上、超时、证书错误)直接抛异常,HTTP状态码错误默认不抛异常。这意味着resp.status_code为404,代码不会停下来,你得自己判断并处理。
所以最佳实践是这个组合拳:网络异常用try/except兜底,业务异常用状态码判断,再用raise_for_status()把4xx和5xx变成异常抛出。两套机制互补而不是二选一。
import requests try: resp = requests.get(url, timeout=10) resp.raise_for_status() # 4xx/5xx 会抛 HTTPError except requests.exceptions.Timeout: print("请求超时") except requests.exceptions.ConnectionError: print("网络连接失败") except requests.exceptions.HTTPError: print(f"HTTP错误: {resp.status_code}") except requests.exceptions.RequestException: print("其他请求异常")RequestException是所有异常的基类,实际代码里可以把它作为最后的兜底分支,避免漏掉某些边缘错误导致直接崩溃。
5.2 误用乱码、JSON解析失败、附加重定向
编码问题。用.text拿到乱码,根因是requests猜错了编码。它有自己的一套编码探测逻辑,但中文网站常被猜错变成ISO-8859-1。这时候看resp.encoding属性,如果不对就手动指定:
resp.encoding = "utf-8" print(resp.text)还有一种更小众的情况:响应头里没声明编码,且页面是GBK编码,认准resp.apparent_encoding也可以得到探测结果——这个属性会对整体内容做统计分析,准确率不错,但会额外消耗性能,只需要时可以调用生僻数据响应才用。
JSON解析失败。这个前面提过,resp.json()默认是基于json.loads(resp.text)实现的,遇到非JSON内容必然报错。实践中我的防坑写法如下:
import json def try_parse_json(resp): ctype = resp.headers.get("Content-Type", "") if "application/json" not in ctype: return None try: return resp.json() except ValueError: return None重定向。默认情况下requests自动跟随重定向,resp.url是最终地址,resp.history里保留历史响应列表。但有些场景不能自动跟随,比如POST请求被302到别的地址后用GET重放,这会造成业务数据异常。此时用allow_redirects=False关掉跟随,自己处理重定向逻辑。这里有个经验:查看resp.history的长度和状态码,能快速定位“跳转链路异常”类问题——比如某个地址循环重定向,浏览器一开始报ERR_TOO_MANY_REDIRECTS,脚本端的表现就是抛出TooManyRedirects异常。
5.3 一个带重试与回退的稳定请求函数
前面讲过的每个技能都可以组装进一个可复用的请求函数里,实际项目中我几乎一直在用差不多的模板:
import time import requests def request_with_retry(method, url, *, retries=3, backoff=0.5, **kwargs): for attempt in range(retries): try: resp = requests.request(method, url, timeout=kwargs.pop("timeout", (3, 10)), **kwargs) if resp.status_code in (500, 502, 503, 504): raise requests.exceptions.HTTPError(f"Server error {resp.status_code}") return resp except (requests.exceptions.Timeout, requests.exceptions.ConnectionError, requests.exceptions.HTTPError) as e: if attempt == retries - 1: raise wait = backoff * (2 ** attempt) print(f"第 {attempt + 1} 次失败,{wait:.2f}s 后重试: {e}") time.sleep(wait)核心思想就两点:对网络波动类的异常做指数退避重试,对确定性的业务错误(比如参数不对返回400)绝不重试——重试只会增加服务端压力,没有任何收益。
6. 性能与工程化:从单次请求走向规模化
6.1 连接池与Keep-Alive的工程意义
很多人在本地脚本里请求几十个接口觉得没问题,一放到生产环境就发现速度慢得离谱。一个常见原因是每次请求都新建连接,而TCP握手和TLS握手的开销很大。requests通过Session和底层的urllib3连接池来复用连接,但默认池大小有限制。
adapter = HTTPAdapter( pool_connections=50, # 不同主机缓存的最大连接数 pool_maxsize=50, # 同一主机连接池的上限 max_retries=3, ) s = requests.Session() s.mount("https://", adapter)pool_maxsize调大到50后,同一主机的并发请求就可以复用已有的TCP连接,省掉每次握手的开销。量级上感受一下:本地HTTP站点,复用连接后吞吐量往往能提升3到5倍,加上多线程效果更明显。
6.2 并发请求的正确打开方式
requests本身是同步阻塞的,一个请求必须等响应回来才能继续。要高并发必须借助线程或进程。写一个简单的多线程抓取模板:
from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_one(url): with requests.Session() as s: resp = s.get(url, timeout=10) return resp.status_code, url urls = ["https://api.example.com/items/" + str(i) for i in range(100)] with ThreadPoolExecutor(max_workers=8) as executor: futures = [executor.submit(fetch_one, url) for url in urls] for future in as_completed(futures): code, url = future.result() print(code, url)ThreadPoolExecutor里每条线程持有自己的Session,避免了线程安全问题。如果你需要真正意义上的异步IO并发(比如同时挂几千个请求不阻塞事件循环),那就不该用requests了,aiohttp是更合适的替代方案。选型逻辑很简单:请求量一百以下、逻辑同步简单,requests加线程池就足够;请求量上千、需要处理长连接,换异步库更省资源。
6.3 爬虫场景的规范与边界
requests加爬虫是高频组合,这里必须提醒几个规范性问题。首先,爬虫的目标站点有访问频率限制的,必须做限速——在请求之间sleep一个随机间隔,而不是固定间隔,固定间隔的模式太容易被识别,随机间隔更像人工行为。其次,尽量读取站点给出的robots规则,有些站点明确禁止爬取的内容就别碰。再者,脚本要设置合理的超时和重试,避免对服务端造成过大压力。
更严谨的做法是:自建一个简单的请求调度类,控制最大并发数和QPS,超过阈值就排队等待。这个类本质上是个轻量限流器,几十行代码就能写出来。把调度逻辑和请求逻辑分开,后续切库、加代理、改限速策略都只需要改一个地方。
7. 高频问题速查表与我的避坑清单
| 报错信息 | 常见原因 | 解决方案 |
|---|---|---|
ConnectionError | DNS解析失败或目标端口不通 | 检查网络,尝试代理或换DNS |
Timeout | 服务端响应慢或防火墙丢包 | 调大timeout,重试机制兜底 |
SSLError | 证书过期或自签证书 | 指定verify证书路径,或测试环境verify=False |
JSONDecodeError | 响应不是合法JSON | 先看Content-Type,再尝试resp.json() |
TooManyRedirects | 重定向循环或联动超过10次 | 设置allow_redirects=False手动分析链路 |
MissingSchema | URL没带http/https前缀 | 检查URL是否完整 |
InvalidURL | URL里有非法字符 | 用params=传参避免手拼 |
避坑清单是最后我想额外强调的几条:
- 永远设置timeout。这是我写生产代码的铁律,没有例外。裸奔的请求一旦遇到慢接口,线程池直接被打满,整个服务雪崩。
- 不要用全局Session做并发。Session对象不是线程安全的。要么每个线程一个Session,要么加锁。
resp.ok不等于业务成功。200状态码只代表HTTP传输成功,业务Code可能还是失败。对接API时一定要检查业务字段。- 代理环境要优先配置。内网开发常年有代理,requests会读取环境变量中的
HTTP_PROXY和HTTPS_PROXY。如果你在代码里直接用proxies={"http": ...}覆盖了环境变量,会导致本来能走的代理失效——这个坑我至少帮同事排查过三次。 - 打印异常时,用
repr(e)而不是str(e)。有些requests异常在str()下信息不完整,repr()能暴露更多上下文,排查效率提升明显。
我个人在实际项目里感受最深的一点是:requests真正好用的地方不在于它多花哨,而在于它把HTTP交互里的“确定性”做到了极致。一旦把Session管理、超时重试、异常处理这些细节固定成一套标准模板,后续任何项目里都能快速套用,省下来的时间可以拿去做真正的业务逻辑。建议你从现在开始,把这套模板沉淀到自己的工具库里,以后写网络请求相关的代码,会顺畅很多。