首页 / 资讯中心 / 文章详情

Python requests库实战:请求参数、会话管理与异常重试全解析

Python requests库实战:请求参数、会话管理与异常重试全解析 ★ FEATURED ARTICLE
1. urllib用着别扭requests就是来解决这个痛点的先说个我早期的经历。刚开始写爬虫或者调接口的时候用的是Python自带的urllib那时候最崩溃的一件事是明明只是发个GET请求带几个查询参数得自己手动拼URL、手动处理编码、还要管Cookie、管重定向、管异常类型。代码写着写着就变成一坨防御式编程——每个潜在出错的地方都要try-except兜底生怕哪个环节悄悄崩溃。后来切到requests第一次执行requests.get(https://api.example.com/data)的时候那种清爽感是真的回不去了。这个库之所以成为Python生态里网络请求的事实标准核心原因只有一句话它把HTTP请求的复杂度藏到了简洁的API背后把最常用的操作做到了一行代码的粒度。我自己对这个库的定位是这样的requests不是一个功能堆砌的全家桶而是一个把HTTP协议里80%的日常诉求打磨到极致的工具。剩下20%的极端需求它也会给你留扩展口子。这篇文章不会去抄官方文档我会从实际使用者的视角把请求参数、会话管理、超时控制、异常重试、文件传输和证书验证这些模块逐个拆开讲清楚每个选择背后的原因以及我在真实项目里踩过的坑。适合看这篇内容的读者有两类一类是刚接触Python网络编程想从urllib过渡到requests的新手另一类是已经在用requests但经常遇到超时报错、连接池失效、SSL证书异常想搞清楚底层机制的进阶开发者。不管属于哪一类我相信读完都能对自己的请求代码有新的理解。2. 核心请求API拆解一次请求真正发生了什么2.1 参数传递别自己拼URL我见过太多人写这样的代码url https://api.example.com/search?keyword quote(keyword) page str(page) resp requests.get(url)这种做法有几个隐患一是keyword里如果带特殊字符quote函数转义不完整就会把请求弄坏二是手动拼接的参数一旦多了代码可读性会急剧下降三是如果哪天要改成POST传参你得整体重构。正确姿势是用params参数让requests帮你处理URL编码resp requests.get( https://api.example.com/search, params{ keyword: Python 爬虫, page: 2, page_size: 20, tag: [tutorial, guide] # 列表会自动展开为多个同名参数 } ) print(resp.url) # 实际请求的URL这里有个容易被忽略的细节params里值为None的键会被自动忽略值为True/False的布尔类型会被序列化成True/False字符串。如果你对接的接口需要小写布尔值就得自己先处理成true/false。另外当你需要同时传query参数和path参数时建议用占位符加url参数resp requests.get( https://api.example.com/users/{user_id}/orders, params{status: paid}, url... )实际上requests没有url这个参数上面的写法是我随手写的错误示范别学。正确做法是用f-string或者格式化字符串把路径部分拼好再把查询参数交给paramsuser_id 1024 resp requests.get( fhttps://api.example.com/users/{user_id}/orders, params{status: paid, page: 1} )2.2 请求头与Cookies伪装与身份保持默认情况下requests发送的请求头里有User-Agent值类似python-requests/2.31.0。这个默认UA在爬虫场景下基本等于明牌告诉对方我是脚本很多网站会直接拒掉。所以项目里我习惯设置一个统一的默认请求头session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, Accept: application/json, text/plain, */*, Connection: keep-alive })在单次请求里也可以用headers参数临时覆盖两者的优先级关系是单次请求的headers会合并到Session的headers之上同名字段以单次请求为准。Cookie这块requests的cookies参数可以传一个dict或RequestsCookieJar对象。但更常用的是让Session自动维护Cookie这样你登录一次之后后续请求会自动带上会话Cookielogin_resp session.post(https://api.example.com/login, json{ username: demo_user, password: ******** }) # 此时session里面已经自动保存了服务端下发的Cookie profile session.get(https://api.example.com/profile)这里我想强调一点如果服务端返回的Set-Cookie带有HttpOnly属性浏览器环境里JS读不到但requests没有这个限制它会照单全收。所以用requests做自动化登录比在浏览器里模拟要省事得多。2.3 JSON数据json和data别混用POST请求传数据很多人刚开始分不清data和json两个参数的区别。简单说data传的是表单编码的数据requests会把它编码成application/x-www-form-urlencoded。json传的是Python对象dict、list等requests会序列化成JSON字符串并自动设置Content-Type: application/json。# 表单格式 resp session.post( https://api.example.com/form, data{field1: value1, field2: value2} ) # JSON格式 resp session.post( https://api.example.com/api, json{field1: value1, field2: value2} )如果你用data传一个dictrequests默认不会帮你转JSON服务端拿到的就是表单格式。很多接口排查半天为什么收到的不是JSON十有八九是这里用错了。提示json参数在requests 2.4.2版本之后才引入。如果你在维护老项目可能遇到的是datajson.dumps(payload)加手动设置headers的写法本质上效果一样但代码丑很多。3. 会话与连接复用的底层逻辑为什么你的批量请求又慢又不稳定3.1 直接请求与Session的本质区别一个最常见的性能误区在循环里每次调用requests.get()代码看着简洁但每一次调用都会创建新的TCP连接、完成TLS握手然后用完就断开。假设你要抓1000个页面那就是1000次完整的连接建立过程其中握手开销可能占了总耗时的一半以上。# 不推荐每个循环都新建连接 for url in urls: resp requests.get(url) parse(resp.text) # 推荐复用一个Session session requests.Session() for url in urls: resp session.get(url) parse(resp.text)requests.Session()底层使用了urllib3的连接池。当你用同一个Session发请求时连接会被池化复用。具体点说HTTP/1.1的Keep-Alive机制让TCP连接在请求结束后不立即关闭而是回到连接池里待命后续指向同一主机的请求可以直接复用这条连接。连接池默认大小和最大复用数我需要说一下requests通过HTTPAdapter控制默认每个主机池大小为10池内最大连接数为10。如果你的并发数超过10就会有请求排队等待空余连接。这个参数在需要高并发时可以调大import requests from requests.adapters import HTTPAdapter session requests.Session() adapter HTTPAdapter(pool_connections50, pool_maxsize50) session.mount(https://, adapter) session.mount(http://, adapter)pool_connections是缓存连接的主机数量pool_maxsize是每个主机最多缓存的连接数。我一般会把两者设为相同值避免理解上的混淆。3.2 超时配置不设置Timeout等于裸奔我接手过很多线上故障其中接口卡死的高发原因就是请求方没设超时。服务端因为某种原因不返回也不断开连接客户端就一直在那里等。在苏打饼干厂的生产线上这种问题叫堵料代码里这叫无响应挂起。timeout参数可以传一个数值也可以传一个元组# 统一超时连接和读取都是5秒 resp requests.get(https://api.example.com/data, timeout5) # 分别设置连接3秒读取10秒 resp requests.get(https://api.example.com/data, timeout(3, 10))区别在于连接超时是指建立TCP连接包括TLS握手的等待时间读取超时是指已经建立连接后等待服务端返回数据块的最长时间。为什么建议分开设因为很多接口响应慢是业务逻辑导致的连接很快但读取很慢如果你统一设一个5秒可能连接其实只需要0.1秒但读取数据需要4秒一旦超过5秒整体就报错。分开设置可以更精确地控制两段不同阶段的容忍度。注意timeout不是整个请求的总时间上限而是连接阶段和读取阶段各自的超时时间。如果你需要整体限时得用别的手段比如配合multiprocessing或者asyncio。3.3 连接失败后的重试别让偶发抖动演变成故障网络请求总有偶发失败可能是DNS解析抖动、连接被重置、服务端短暂重启。一次失败不代表服务不可用但没有重试机制的代码一次抖动就会让整个任务失败。用requests内置机制实现重试需要借助urllib3的Retry类配合HTTPAdapterfrom urllib3.util.retry import Retry from requests.adapters import HTTPAdapter retry_strategy Retry( total3, # 总重试次数包括连接和读取 connect3, # 连接阶段重试次数 read3, # 读取阶段重试次数 backoff_factor1, # 退避因子0.5 * backoff_factor * (2 ** retry_count) status_forcelist[500, 502, 503, 504], # 遇到这些状态码也重试 allowed_methods[GET, POST, PUT, DELETE] # 允许重试的方法 ) adapter HTTPAdapter(max_retriesretry_strategy) session requests.Session() session.mount(https://, adapter) session.mount(http://, adapter)backoff_factor的计算方式第一次重试等待0.5 * 1 0.5秒第二次0.5 * 2 1秒第三次0.5 * 4 2秒呈指数退避。这个策略的价值在于如果服务端确实在过载你立刻高频重试反而会把对方压垮退避机制给服务端留了恢复窗口。这里要特别提醒allowed_methods里我加了POST是因为我们的业务接口设计成了幂等同一个请求重复执行结果一致。如果你的POST不是幂等的比如下单接口千万不能重试否则用户可能被扣两次款。这在支付场景是个极为严肃的问题我见过不止一次因为无脑重试导致重复下单的线上事故。4. 异常、状态码与证书验证网络请求的三种翻车方式4.1 异常层级抓住你该抓的那一层requests的异常体系不算复杂核心是一个树状结构RequestException ├── ConnectionError ├── Timeout │ ├── ConnectTimeout │ └── ReadTimeout ├── HTTPError ├── TooManyRedirects ├── ProxyError ├── SSLError └── InvalidURL写异常处理时最常见的坑是except Exception一把抓或者只抓requests.exceptions.RequestException但不区分具体类型。我的建议是分级处理超时可以重试连接错误可以重试但SSLError基本是配置问题重试也没用应该直接告警。一个我常用的模式import requests from requests.exceptions import Timeout, ConnectionError, SSLError try: resp requests.get(https://api.example.com/data, timeout(5, 15)) resp.raise_for_status() except Timeout as e: # 超时记日志、走重试队列 log.error(ftimeout: {e}) except ConnectionError as e: log.error(fconnection error: {e}) except SSLError as e: log.error(fssl error, need check cert: {e}) except requests.exceptions.HTTPError as e: # 4xx/5xx根据状态码决定是否重试 status e.response.status_code log.error(fhttp error: {status})resp.raise_for_status()是个好东西。如果服务端返回4xx或5xxrequests默认不会抛异常你拿到resp还要手动检查resp.status_code。调用raise_for_status()之后非2xx状态码会直接抛HTTPError让异常处理逻辑统一。4.2 响应内容text、content与编码陷阱resp.text返回的是解码后的字符串resp.content返回的是原始字节。两者之间的桥梁是编码推断。requests的编码推断顺序是先看HTTP头里的Content-Type是否带charset如果没有会用chardet或charset_normalizer去猜resp.content的编码。问题在于当服务端返回的Content-Type没写charset而内容又是非UTF-8编码比如GBK时猜测结果可能不准导致resp.text出现乱码。我自己写爬虫时的习惯resp requests.get(url) # 先看响应头是否指定了编码 print(resp.encoding, resp.apparent_encoding) # 如果resp.text乱码手动指定编码 if resp.encoding is None or resp.encoding.lower() ! utf-8: resp.encoding resp.apparent_encoding # 用猜测结果 # 或者直接强制指定 resp.encoding gbk text resp.text关于resp.apparent_encoding它是基于响应内容字节推断出的编码准确率在大多数场景下还可以但要注意对于中英文混排且内容特别短的页面推断可能失败。真遇到乱码我一般会先去浏览器开发者工具里看响应头拿到真实的charset再写死。4.3 SSL证书验证verifyFalse的代价很多人在请求自签名证书或内网测试环境的HTTPS接口时会看到SSLError然后搜到一种解法——verifyFalse。确实这个参数能关掉证书验证让请求顺利发出去。但我必须说清楚代价verifyFalse等于告诉requests我不验证服务器身份这会让中间人攻击变得可能。如果你的请求里带登录凭据或敏感数据关闭验证等于把这些数据暴露在风险中。生产环境里正确的做法是给requests提供CA证书# 方式一指定单个CA证书文件 resp requests.get(https://internal.example.com, verify/path/to/ca_bundle.crt) # 方式二指向包含多个CA证书的目录 resp requests.get(https://internal.example.com, verify/path/to/cert_dir)如果是内网自签名证书建议把该证书加入系统的CA信任链或者在代码里用verify显式指定而不是一关了之。另外提一嘴当你使用verifyFalse时requests会抛出一个InsecureRequestWarning如果你不想在日志里刷屏可以这样屏蔽import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)但这只是掩盖警告不是解决问题的办法。我只有在本地联调、且确认网络环境安全的情况下才会这么做。5. 文件上传下载、代理与身份认证企业级场景的高频需求5.1 文件上传files参数的两种用法上传文件时requests的files参数是最直观的。最简形式files {file: open(report.pdf, rb)} resp requests.post(https://api.example.com/upload, filesfiles)进阶用法可以手动指定文件名和Content-Typefiles { file: ( report_final.pdf, # 文件名 open(report.pdf, rb), # 文件对象 application/pdf, # Content-Type {Expires: 0} # 额外头信息可选 ) } resp requests.post(https://api.example.com/upload, filesfiles)这里有个性能相关的细节如果用open()打开文件然后传给filesrequests会惰性读取文件内容不会一次性把整个文件加载进内存。但如果你直接把整个字节串传给files比如files{file: (a.txt, content_bytes)}就会占用对应大小的内存。大文件上传推荐用文件对象方式。此外data和files可以同时使用适合那种既要传表单字段又要传文件的接口resp requests.post( https://api.example.com/upload, data{description: 季度报表}, files{file: open(q4.pdf, rb)} )5.2 大文件下载流式处理避免内存爆炸下载大文件比如几百MB的安装包或视频如果直接用resp.content整个文件会全部加载到内存里。几百MB甚至上GB的文件直接把程序内存打爆。正确的做法是开启streamTrue然后分块写入磁盘resp requests.get(https://cdn.example.com/large_file.zip, streamTrue) resp.raise_for_status() with open(large_file.zip, wb) as f: for chunk in resp.iter_content(chunk_size1024 * 512): if chunk: # 过滤掉保持连接的空块 f.write(chunk)iter_content还有个兄弟叫iter_lines专门按行读取适合处理流式接口比如SSE推送或逐行日志。用streamTrue时有一个需要留意的点如果你设置了流式读取但没有读完整个响应连接不会自动释放回连接池。这时候要主动调用resp.close()否则连接池里会堆积未完成的连接最终导致连接耗尽。resp requests.get(url, streamTrue) try: first_line next(resp.iter_lines()) # 只读了一行剩下的不读了 finally: resp.close()5.3 代理与身份认证绕过网络限制的合规姿势企业内网环境或者需要访问某些受限网络资源时proxies参数是标配proxies { http: http://proxy.example.com:8080, https: http://proxy.example.com:8080 } resp requests.get(https://api.example.com/data, proxiesproxies, timeout10)如果需要代理认证直接把用户名密码写在URL里proxies { http: http://user:passwordproxy.example.com:8080, https: http://user:passwordproxy.example.com:8080 }另外环境变量里的HTTP_PROXY、HTTPS_PROXY也会影响requests的行为。如果代码里没显式设置proxiesrequests会读取这些环境变量。我遇到过排查半天代理不走结果是环境变量里另一个代理设置在做怪。如果要强制忽略环境变量可以显式传proxies{http: None, https: None}。身份认证方面requests支持多种认证方式# HTTP Basic Auth最常用 resp requests.get(https://api.example.com/private, auth(username, password)) # 等价写法requests.auth.HTTPBasicAuth from requests.auth import HTTPBasicAuth resp requests.get(https://api.example.com/private, authHTTPBasicAuth(username, password)) # Digest认证 from requests.auth import HTTPDigestAuth resp requests.get(https://api.example.com/private, authHTTPDigestAuth(username, password))很多内部系统的API走的是Basic Auth记住用户名密码不要直接写在代码里用环境变量或独立的配置文件管理import os username os.getenv(API_USERNAME) password os.getenv(API_PASSWORD)6. 并发场景下的requests实践与最终的几个经验6.1 多线程下的Session隔离requests的Session不是线程安全的这一点很多人容易忽略。如果你在多线程环境里共享同一个Session遇到连接池操作和Cookie读写的并发冲突偶尔会报奇怪的状态码或者连接错误。我的经验是线程与Session一一对应。每个线程自己创建Session线程结束就关闭。用ThreadPoolExecutor时这样写from concurrent.futures import ThreadPoolExecutor import requests def fetch_one(url): session requests.Session() try: resp session.get(url, timeout10) resp.raise_for_status() return resp.json() finally: session.close() urls [https://api.example.com/item/ str(i) for i in range(100)] with ThreadPoolExecutor(max_workers8) as pool: results list(pool.map(fetch_one, urls))如果你确实想共享Session至少要做加锁保护但那样并发收益会大打折扣。所以每线程一个Session是并发场景下最简单可靠的方案。6.2 使用requests还是httpx什么时候考虑迁移不能否认现在的httpx在异步支持上确实比requests有优势。requests本身不支持async/await在异步场景下你得用aiohttp或者httpx。但如果你写的是同步代码requests的生态成熟度和文档质量依然是首选。我的判断标准很简单项目完全同步、依赖简单、上线多年稳定运行不折腾继续用requests。项目需要高并发异步请求直接选httpx或者aiohttp别在requests上硬拗并发。需要HTTP/2支持requests不支持httpx支持。但这种需求在常规业务里很少见。工具选型的关键是匹配场景而不是追逐新潮。我见过不少团队因为requests太老把代码迁移到httpx结果异步没用好反而引入一堆兼容性问题。一个跑得好好的同步代码真的没必要为了fashion去改架构。6.3 最后分享几个经验写到这里结合这些年用requests踩过的坑我最后想分享几条关于这个库的实操体会第一resp.raise_for_status()一定要养成习惯。很多接口在出错时也会返回200但业务代码里的error_code字段标识了失败。你可以在拿到响应后先检查HTTP状态再检查业务状态码两层校验能挡掉大部分问题。第二timeout永远要设置。不管代码多简单、接口多稳定都要加一个合理的超时。这是我经历过线上故障之后养成的铁律没有超时的请求就是一个不可控的定时炸弹。第三凡是涉及金额、订单这类非幂等操作重试逻辑里必须把POST排除在外。哪怕服务端偶发5xx你也要人工排查确认请求是否真的没有到达服务端再决定是否重试。第四Session用完之后记得session.close()。这一步不是必须的但它能确保所有连接正常归还在某些长期运行的应用里漏掉这一步会让文件描述符缓慢增长。第五调试网络请求时requests自带的结构化打印非常好用。你可以用resp.request.headers查看实际发送的请求头用resp.request.body查看请求体配合resp.url确认最终请求的URL。这三个属性是排查问题时的第一手证据。requests这个库最令人舒服的地方在于它把HTTP协议里绝大多数繁琐细节都收拢了让你能专注于业务逻辑本身。但简单不等于不需要理解。真正理解背后的连接管理、超时语义、异常分层和编码机制你才能在关键时刻hold住问题而不是被问题追着跑。希望这篇内容能帮你把requests用得更透。
阅读完成 · 觉得有帮助?
咨询建站