
凌晨两点我盯着终端里疯狂滚动的报错日志——exceeded retry limit, last status: 429 too many requests——那是我用Python写的一个数据采集脚本跑了不到一小时就被服务端限流拍死在沙滩上。老实说这类错误对用requests库的开发者来说不陌生真正让我难受的不是429本身而是我发现自己对requests的理解还停留在“发个get()拿到响应”的层面一旦遇到重试策略、会话保持、版本管理这些实战问题完全是在靠猜。requests是Python生态里使用率最高的HTTP客户端库没有之一。爬虫、接口测试、自动化脚本、后端服务调用几乎都绕不开它。但很多人包括当时的我对它的认知是断层的——会用requests.get(url)却讲不清参数怎么传、Session为什么能保持登录、429限流怎么通过重试机制优雅化解。这篇文章我不打算写官方文档的翻译版而是基于我自己踩过的坑把requests从安装到实战、从get()到重试机制的完整链路拆开讲一遍希望能给正在用requests做爬虫或接口调用的朋友一些真正可复用的经验。1. 为什么Python写HTTP请求绕不开requests1.1 从urllib到requests一次开发者体验的跨越Python标准库其实自带HTTP客户端——urllib。我早期写爬虫用的就是它说实话能用但痛苦。一个简单的GET请求urllib要写urllib.request.urlopen()处理响应要手动decode设置超时要单独传timeout参数抛出来的异常还得用urllib.error里的各种类去接代码又长又绕。requests的设计目标就是“让HTTP请求对人类友好”K大神Kenneth Reitz做这个库时反复强调的核心理念HTTP请求应该是自然的、可读的、符合直觉的。用requests写同样的GET请求import requests resp requests.get(https://httpbin.org/get, timeout10) print(resp.text)三行完事。响应对象直接帮你搞定编码解析、JSON序列化、状态码判断这些杂活。这不是炫技而是降低了所有Python开发者发起HTTP请求的门槛所以不管做爬虫还是调API大家第一反应都是import requests。1.2 requests库到底帮我们干了什么往深了说requests并不是在底层重新发明了HTTP协议它是对Python标准库http.client和urllib3的一层封装。像连接池管理、SSL验证、重试机制这些重活实际上由urllib3完成requests提供的是简洁的API层。这点很重要因为当你遇到exceeded retry limit这类错误时排查方向其实会涉及两层requests这层你能控制的是请求参数、Session配置、超时与重试的显式设置而底层urllib3的默认行为比如默认重试次数、连接池大小也会影响最终表现。理解了分层关系后面调参才有的放矢。用户只需要写几行业务代码剩下的TCP连接、TLS握手、HTTP报文构造、响应解析全部被吞进库内部处理。这也是它学起来快的原因——你不需要成为HTTP协议专家就能发请求但如果想真正用好它知道一点底层机制会非常有帮助。1.3 安装与首次运行版本先看清安装requests的方式很简单大多数时候一条命令就够了pip install requests但pip install背后有几个隐藏坑。如果你是在Windows上手动装的Python很可能遇到这样一段提示Defaulting to user installation because normal site-packages is not writeable我不少朋友看到这句话直接懵了其实内容很简单当前Python环境的site-packages目录没有写权限pip自动转成了--user安装模式把包装进了当前用户目录。碰上这情况建议先确认一下自己是不是应该用虚拟环境别图省事硬往系统环境塞包。后面第5章我会单独讲环境和升级的问题这里先不展开。装完之后可以顺手验证下版本import requests print(requests.__version__)我在2025年常用的版本是2.32.x如果你看到2.31以下的老版本建议找时间升级因为涉及一些安全修复和连接行为优化。2. 一个get()请求的一生从URL构造到响应解析2.1 params、headers、timeout的配合逻辑热搜词里“requests库的get方法”排得很靠前说明这是绝大多数人接触requests的第一步。get()最基础的用法确实是传一个URL但真实场景里几乎没有只传URL就够的请求。带参请求最常见的写法是import requests url https://api.example.com/search params {keyword: python, page: 1, limit: 20} headers {User-Agent: Mozilla/5.0} resp requests.get(url, paramsparams, headersheaders, timeout15)这里要重点理解params的机制——requests不会让你手动在URL后面拼?keywordpythonpage1它会把params字典里的键值对做URL编码后拼到地址上。这样做的好处一是方便扩展二是字典里的特殊字符比如中文、空格、会被正确处理。headers是另一个常被忽略的参数。很多接口要对来源做校验最基本的就是User-Agent。用requests默认的python-requests/x.x.x标识去访问某些网站很容易被当成爬虫识别。我不是教你伪装成浏览器去欺骗站点而是想说明带上合理且真实的UA是网络请求的基本礼貌能显著降低被误伤的几率。对于timeout我的经验是必须在生产代码里设置千万别省。不设timeout意味着你的程序可能因为服务端不响应而无限挂起这在爬虫和定时任务里尤其致命。合理取值要看具体接口——一般外部API我给5到10秒内网服务给3秒批量采集时甚至可以给15秒以上避免网络波动导致频繁超时。2.2 响应对象里面的三个常用出口text、content、json()resp.text返回的是解码后的字符串resp.content返回的是原始字节流resp.json()则是直接帮你把JSON格式的响应体解析成字典或列表。很多新手分不清三者的使用场景我做个简单的对照方法返回类型适用场景注意事项resp.textstr网页源码、纯文本接口编码是requests根据响应头猜测的可能不准resp.contentbytes图片、文件下载、未知编码流保留原始数据适合再编码或写文件resp.json()dict/listJSON格式APIJSON解析失败会抛异常要先判断状态码实际工作中最忌一上来就resp.json()。如果服务端返回了500错误页或一个纯文本的报错信息json解析会直接炸。更稳妥的做法是先用resp.status_code判断或直接用resp.raise_for_status()——这个方法在HTTP状态码为4xx/5xx时会抛出requests.exceptions.HTTPError省得你手写一堆if判断。处理响应编码有几个经验如果resp.text出现乱码原因大概率是服务端没有声明字符集或声明错误此时用resp.encoding手动指定目标编码再重新获取resp.text即可如果是下载文件务必要用resp.content配合二进制模式写文件用text会导致文件损坏。2.3 状态码处理与异常体系别和if语句硬刚我在很多项目里看到过这种代码resp requests.get(url) if resp.status_code 200: return resp.json() else: return None不是说不能用而是这个写法把大量可能发生的异常都吞掉了。requests本身有一套完善的异常体系用好它代码会干净很多requests.exceptions.ConnectionError连接失败比如DNS解析不了、目标拒绝连接requests.exceptions.Timeout请求超时包括连接超时和读取超时requests.exceptions.HTTPErrorHTTP状态码非2xx配合raise_for_status()触发requests.exceptions.RequestException上面所有异常的基类所以我的习惯是import requests from requests.exceptions import RequestException try: resp requests.get(url, timeout10) resp.raise_for_status() except RequestException as e: print(f请求失败: {e}) else: return resp.json()这样抛出来的错误信息包含具体的失败原因排查问题时比拿到一个None清晰得多。3. 爬虫实战Session、请求头与频率控制3.1 Session对象为什么连续请求会吃闭门羹爬虫初学者最容易遇到的困惑我用requests.get()明明能拿到首页为什么下一步带着Cookie去访问个人中心就提示未登录原因很简单——每次直接调用get()创建的都是一次独立的连接请求之间不共享Cookie服务端自然认不出你。requests.Session()解决的就是这个问题。Session对象在底层维护了一个连接池并且自动管理Cookie。你第一次用Session请求时服务端返回的Set-Cookie会被存进Session的CookieJar里后续同Session发起的请求会自动带上这些Cookieimport requests session requests.Session() # 第一次请求会携带登录信息服务端返回Cookie session.post(https://example.com/login, data{username: test, password: 123456}) # 后续请求自动携带登录后的Cookie resp session.get(https://example.com/my/profile)除了CookieSession还会复用底层TCP连接减少了重复建连的开销。批量请求几十上百条时性能差距非常明显。我在采集脚本里基本都会建一个Session再配上统一的headers和超时设置整个会话期间的代码会干净很多session requests.Session() session.headers.update({User-Agent: Mozilla/5.0}) session.timeout 103.2 请求头伪装与Cookie管理合规与效率的平衡接着说说请求头。很多接口只靠User-Agent做基础识别所以一个符合常规浏览器习惯的UA能挡掉大量误伤。除了UAReferer和Origin在部分站点会被校验这个就取决于具体接口了没有通用公式但抓包看一次正常浏览器请求照着填一般不会错。Cookie方面requests的Session已经足够好用但有一个细节值得注意如果需要手动往Session里塞Cookie不要用字符串拼接用requests.cookies构造from requests.cookies import cookiejar_from_dict session requests.Session() session.cookies cookiejar_from_dict({sessionid: abc123, token: xyz})这样CookieJar的结构是标准的后续更新Cookie也能正常合并。强调一下这里说模拟请求头指的是用合理的手段访问公开数据。抓取数据前务必确认目标站点是否允许爬虫注意robots.txt控制请求频率不要对服务端造成压力。用requests写爬虫很容易但实际上“能爬到”和“可以爬”是两码事合规是第一位的。3.3 频率控制别让脚本把服务端打挂大部分限流问题不是被刻意针对而是客户端请求太密集触发了服务端保护。我的经验是无论目标接口多“皮实”批量请求之间必须加间隔。最简单的做法是time.sleep()但硬编码固定间隔有个坏处间隔太短容易被封间隔太长采集效率太低。更合理的策略是加入随机抖动import time import random time.sleep(random.uniform(0.5, 1.5))这样从服务端看你的请求间隔不是一个整齐的固定节奏更像人类操作触发风控的概率会低一些。再进阶一点可以用一个自适应的限速器——遇到429就把间隔翻倍持续正常就把间隔逐渐调小。这类逻辑我会放在一个独立的请求层里统一管理而不是散落每个爬虫脚本中。4. 429限流全排查从报错到重试策略落地4.1 先搞懂429到底在说什么回到开头那个exceeded retry limit, last status: 429 too many requests。这个报错通常不是requests本身直接抛出来的而是来自urllib3的Retry模块——请求触发了重试机制但每次重试后服务端依然返回429直到超过重试上限。HTTP 429Too Many Requests的含义是“请求过于频繁已被服务端限流”。服务端通过X-RateLimit-Limit和X-RateLimit-Remaining等响应头告知额度或者通过Retry-After头告诉客户端“你需要等多少秒再试”。用requests写代码时如果你不做任何重试配置默认情况下请求发出后拿到429响应并不会自动重试代码会直接以429状态码返回——很多朋友第一次遇到限流就是这么懵的。当429出现时问题不再是“怎么发送请求”而是“如何科学地等待重试”。无脑重试第一波可能侥幸通过但如果你完全不等待就立刻狂点服务端会进一步收紧策略甚至会直接封IP。4.2 一次实测从exceeded retry limit到跑通全流程我之前写过一个小任务需要批量调用某个公开的搜索接口最初版本是这样的import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session requests.Session() retry Retry( total3, status_forcelist[429, 500, 502], backoff_factor0.5, ) adapter HTTPAdapter(max_retriesretry) session.mount(http://, adapter) session.mount(https://, adapter)配置里的backoff_factor0.5很关键。Retry的退避时间计算公式是{backoff_factor} * (1 retry_count)也就是第一次重试前等0.5秒第二次等1秒第三次等2秒成指数递增。这套策略在大多数情况下有效但还有一个容易忽略的点——status_forcelist里填入429后urllib3会去读响应头Retry-After如果目标接口返回了这个头会优先使用其中的值作为等待时长。那次任务一开始没配Retry-After的兼容逻辑因为目标接口压根没返回Retry-After头只靠指数退避硬扛。跑了几个小时后我发现日志里依然出现exceeded retry limit复盘日志后定位到原因那个接口的限流窗口是按分钟计算的高峰时段每分钟只允许30次请求而我的脚本在单线程自动重试模式下一分钟内发起请求数远超了额度。4.3 正确的重试姿势限速器、退避算法与业务降级那次踩坑让我把重试策略升级成了“三层结构”后面再没被429击穿过第一层请求发出前控制速率。我会写一个简单的令牌桶或滑动窗口保证每秒最多N个请求从源头减少429触发概率。第二层重试时遵循指数退避加上随机抖动。纯指数退避有个问题如果所有客户端在同一时间被限流大家同步等待相同的时间后同时重试会再次制造一波流量尖峰。加一个随机扰动可以打散重试节奏。import random import time def retry_with_backoff(func, max_retries5, base_delay1): for attempt in range(max_retries): try: return func() except requests.exceptions.RequestException as e: if attempt max_retries - 1: raise delay base_delay * (2 ** attempt) random.uniform(0, 0.5) time.sleep(delay)第三层重试次数耗尽后的业务降级。程序里出现exceeded retry limit并不代表任务失败更像是一个需要处理的业务分支跳过当前批次、记录日志、稍后集中补偿。我在任务里会把这些失败请求写进一个待重试队列等限流窗口过了再统一处理整体任务不会因为单批失败而中断。对于调用第三方服务的场景务必要读一读对方接口文档里的限流说明。有的服务是按QPS限有的是按每分钟请求总量限有的会明确在响应头里给出配额值。不理解服务端的限流维度客户端这边再怎么调退避参数都是盲调。5. requests库的升级与安装环境避坑5.1 升级前先搞清楚你的包到底装在哪里前面提到了pip install requests时可能遇到“Defaulting to user installation”的提示这个问题在升级时同样常见。很多人执行pip install --upgrade requests后用pip show requests查看版本已经更新了但在Python里import requests后打印的版本号还是旧的原因就是系统里有多个Python环境pip装到了其中一个解释器用的是另一个。遇到这种情况先别急着骂pip。用下面两条命令确认环境where python python -m pip show requestswhere python能看到当前命令行里用的Python解释器完整路径。python -m pip能确保pip和当前Python解释器绑定避免调用到别的pip。5.2 升级requests的完整流程与常见报错升级requests本身不复杂python -m pip install --upgrade requests但有几个高频问题需要专门提。第一如果你长期没升级过pip版本可能太老会遇到pip install命令报错或找不到新版本索引的情况。先升级pippython -m pip install --upgrade pip第二Windows上如果提示权限不足优先用虚拟环境而不是想都不想加sudo或管理员权限。为每个项目建独立的虚拟环境是成本最低的隔离方案也彻底避开“多个环境互相污染”的坑。第三升级后别只盯着requests的版本还要看一眼urllib3的版本。requests对urllib3有版本依赖限制如果你单独装了一个过新或过旧的urllib3可能破坏requests的运行环境。稳妥做法是直接用pip install --upgrade requests让它自己解析依赖不要手动盲目改urllib3的版本。5.3 版本锁定为什么线上环境不能随手upgrade开发环境把requests升到最新版没啥问题但生产环境一定要锁定版本。requests的API设计很稳定但小版本更新可能修正连接行为、安全策略一旦线上某个脚本依赖旧行为升级后可能突然出现登录失效、证书校验失败等诡异问题。我用得比较多的是把项目的依赖写进requirements.txtrequests2.32.3 urllib32.x.y certifi2024.x.y固定版本后不管是本地开发还是CI部署依赖都能保持一致。每次要升级时单独开一个分支跑完全量测试再合并不能图省事直接在服务器上pip install --upgrade了事。6. 进阶思路requests不够用的时候怎么办6.1 异步场景requests和httpx、aiohttp的取舍requests库有一个绕不开的软肋——它是一个同步库。一个请求发出去在拿到完整响应之前当前线程就卡在那里。如果任务是几十个请求串行跑整体耗时基本就是所有请求耗时之和性能天花板很明显。做爬虫时如果追求采集效率通常会切换到异步方案。httpx是一个很接近requests体验的替代品API风格几乎无缝迁移同时支持同步和异步调用aiohttp则更底层一些灵活度更高但需要自己处理的东西也多。一个常见的折中方案是业务逻辑继续用requests数据量大时再用concurrent.futures.ThreadPoolExecutor做多线程并发规避requests的GIL限制。注意并发会放大请求频率更要配合第4章讲的限速器使用不然很容易把目标服务打挂。6.2 证书、代理与自定义适配器的经验requests默认用certifi提供的CA证书做HTTPS校验。某些公司内网或测试环境使用自签名证书直接请求会报SSLError。对这种场景正确做法是把自签名证书加进信任列表或者用verify参数指定CA包路径resp requests.get(https://internal.example.com, verify/path/to/ca.pem)而不是一关了之verifyFalse。关闭证书校验在调试时可以临时用生产环境会引入中间人攻击的风险建议不要这么干。代理方面requests通过proxies参数或环境变量HTTP_PROXY/HTTPS_PROXY支持HTTP代理。我在调试接口时会用一个本地代理工具抓包看请求细节在公司网络环境访问外网接口也经常需要走代理配置方式都是一样的proxies {http: http://10.0.0.1:8080, https: http://10.0.0.1:8080} resp requests.get(url, proxiesproxies)需要说明一句这里讲的代理仅指标准HTTP代理公网访问限制和网络接入问题请按所在网络环境规定执行我不展开。6.3 个人实践搭建一套稳定可复用的请求基础层说到最后我想分享一个让我少踩很多坑的做法不要在每个脚本里重复写requests调用而是把常用逻辑抽成一个独立的请求基础层。它的核心职责包括统一的Session初始化、默认超时与重试配置、日志记录、错误分类、限速控制。伪代码结构大致长这样class ApiClient: def __init__(self, base_url, concurrency_limit5): self.session requests.Session() self.session.headers.update({User-Agent: MyCrawler/1.0}) # 配置重试策略与连接池 retry Retry(total3, backoff_factor1, status_forcelist[429, 500, 502, 503]) adapter HTTPAdapter(pool_connections100, pool_maxsize100, max_retriesretry) self.session.mount(https://, adapter) def get_json(self, path, **kwargs): url self.base_url path resp self.session.get(url, timeout10, **kwargs) resp.raise_for_status() return resp.json()所有具体业务只调用这个基础层底层改连接池、改重试策略时业务代码完全不用动。时间久了你会发现这套基础层才是一个爬虫项目最值得维护的地方它替业务侧屏蔽掉了HTTP协议、限流策略、网络异常这些真正的复杂度。就像我在开头那次429事故里学到的——requests入门只要半天把它用好却需要在真实流量里不断调试和积累。上面这套请求层的雏形是我在多次“重试到崩溃、被限流到封IP”之后逐步改出来的。如果你现在还在每个脚本里随手写requests.get()我确实建议找一个时间把这层抽出来之后的维护成本会低非常多。至于限流和重试的调参没有一次成型的神仙配置我自己的做法是每接一个新目标服务就先跑一个小规模的探针任务观察响应码和响应头再调整重试参数。这种“先探路再批量”的思路比拿全量数据去撞限流阈值要稳得多。