现代 Web API 和数据抓取项目经常会遇到 HTTP 请求失败的情况:服务器可能暂时宕机、网络可能出现短暂故障、目标网站也可能施加速率限制。
在这些情况下,让 Python 脚本自动重试失败的请求可以帮您化解问题。requests 库让在 Python 中发送 HTTP 请求变得简单,但它并不会神奇地消除超时或连接中断等错误。
在本指南中,我们将探讨如何有效使用 Python requests 的重试机制:从利用 requests 内置的功能,到编写带指数退避的自定义重试循环、处理特殊 HTTP 状态码、使用代理进行反封锁等。
请求失败的常见原因
在实现重试机制之前,先了解请求失败的根本原因非常重要。常见原因包括网络问题、服务器问题和客户端错误:
- 网络问题
临时的网络波动可能导致连接错误或超时。例如,网络连接不佳或 DNS 问题可能导致请求根本无法到达服务器。
- 服务器端错误
5xx 范围(500-599)的 HTTP 状态码表示服务器在处理请求时遇到了错误。例如,500(内部服务器错误)或 502(错误网关)表明服务器端出现了问题。此类错误通常是暂时的,服务器可能会在短时间内恢复正常。
- 客户端错误与速率限制
4xx 的 HTTP 状态码通常表示请求本身存在问题。有些是永久性的,比如 404 Not Found 或 401 Unauthorized,除非您修改请求(纠正 URL 或凭据)才能解决。但也有一些属于临时性问题。
429 Too Many Requests 表示您已触及速率限制,服务器要求您放慢请求频率。类似地,403 Forbidden 可能是由于服务器屏蔽了您的访问(例如因为触发了反抓取防护)。
总之,只应对可能属于临时性的问题进行重试。网络波动、超时等异常以及 5xx 服务器错误都是适合重试的情况。而永久性错误(速率限制以外的 400 级别问题)通常不应该盲目重试。
理解失败的原因,有助于判断重试是否值得,还是需要采取其他修复方式(例如纠正错误的 URL 或补充正确的身份验证信息)。
使用 HTTPAdapter 重试 Python 请求
Python 的 requests 库默认不会重试失败的请求,但它提供了实现重试的工具。在底层,requests 使用 urllib3 这个 HTTP 客户端,其中包含一个处理重试逻辑的 Retry 类。
关键在于使用带有自定义 HTTPAdapter 的会话对象。HTTPAdapter 允许您配置一套重试策略,供该会话发出的所有请求使用。
How built-in retries work in requests
开箱即用的 requests 库不会自动重试失败的请求(可能除了重定向请求这类少数特定场景)。这是有意为之的设计,重试行为被交由开发者自行决定。
好消息是,requests 允许您通过适配器机制接入一套 Retry 配置。requests.adapters.HTTPAdapter 可以通过 max_retries 参数进行配置,该参数接受一个来自 urllib3 的 Retry 对象。将该适配器挂载到会话上后,即可为通过该会话发出的请求启用自动重试。
urllib3 的 Retry 类提供了灵活的重试行为:您可以指定允许的总重试次数、哪些 HTTP 状态码应触发重试、哪些请求方法应被重试,以及重试之间如何等待(例如使用退避算法)。
需要注意的是,Retry 逻辑只会应用于通过挂载了该适配器的会话发出的请求,不会影响 requests.get() 等其他未经过该会话的调用。
在接下来的部分中,我们将通过一个示例演示如何用 Retry 配置 HTTPAdapter,并讨论可以调整的关键设置,以实现有效的重试。
示例:使用 Retry 配置 HTTPAdapter
让我们搭建一个基础示例,使用 requests 内置的适配器机制自动重试失败的请求。我们将结合使用 HTTPAdapter 类(从 requests.adapters 导入 HTTPAdapter)与 urllib3 的 Retry 类:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
# Define a retry strategy
retry_strategy = Retry(
total=3, # total retry attempts (excluding the first request)
backoff_factor=1, # factor for delay between retries
status_forcelist=[429, 500, 502, 503, 504], # HTTP codes to retry
allowed_methods=["HEAD", "GET", "OPTIONS"] # methods to retry (None means any)
)
# Create an adapter with the retry strategy
adapter = HTTPAdapter(max_retries=retry_strategy)
# Create a session and mount the adapter to both HTTP and HTTPS
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
# Now use session to make requests
try:
response = session.get("https://httpbin.org/status/503", timeout=5)
print(f"Final status: {response.status_code}")
except Exception as e:
print(f"Request failed: {e}")
在这段代码中,我们配置了一套重试策略,对于某些 HTTP 状态码,最多尝试三次重试(算上最初的请求,总共最多四次)。我们将 status_forcelist 设置为 [429, 500, 502, 503, 504],这意味着出现这些状态码的响应都会被视为可重试。
设置 backoff_factor=1会在重试之间强制加入延迟(我们稍后会解释退避计算方式)。我们把适配器挂载到会话对象上,然后使用 session.get 发出请求。
这样一来,如果目标 URL 返回 503 Service Unavailable,会话会自动重试请求,最多重试三次后才会放弃。
请注意,我们在 get() 调用中加入了 timeout=5。为请求设置超时是一种良好实践,尤其是在使用重试机制时,这样可以避免每次尝试无限期挂起。
需要调整的关键参数
在使用带 Retry 对象的 HTTPAdapter 时,有几个重要参数可以根据您的使用场景进行调整:
- 总重试次数
该参数控制总重试次数(不含最初的请求)。在我们的示例中,我们设置了 total=3,意味着请求失败后最多可以再尝试 3 次。将 total=None 设为不限次数(不建议这样做),将 total=0 则会完全禁用重试。
- 状态码筛选
参数 status_forcelist 是一个应触发重试的 HTTP 状态码列表。默认情况下,除非您指定该列表,否则 urllib3 的 Retry 根本不会基于状态码进行重试。
- 允许重试的 HTTP 方法
默认情况下,只有被视为幂等的“安全”HTTP 方法(如 GET、HEAD、OPTIONS 以及 PUT/Delete)才会启用重试。这样做是为了避免重试非幂等请求(如 POST),因为重复提交表单或购买操作可能产生副作用。
- 退避设置
参数 backoff_factor 是避免对服务器造成轰炸式请求的关键设置。该系数与重试次数相结合,决定了下一次尝试之前需要等待多久。
- 错误处理行为
默认情况下,一旦重试次数用尽,requests 会话会抛出异常(来自 urllib3 的 MaxRetryError,该异常会被封装进 requests.exceptions.RetryError 中)。
通过这些参数,您可以精细调整重试策略的行为。例如,您可能只想在连接错误时重试,而不在 HTTP 错误时重试,反之亦然——这可以通过调整 Retry 的设置来实现。对于许多场景,使用 HTTPAdapter 的内置方式都很方便,而且一旦配置完成,代码也能保持相对简洁。
自定义重试逻辑与装饰器
内置的适配器方式很实用,但有时您需要的自定义程度超出了它所能提供的范围。您可能希望添加日志记录、实现特殊的重试逻辑(比如在失败时刷新身份验证令牌),或处理一些 urllib3 的 Retry 无法覆盖的情况(例如根据成功响应的内容决定是否重试)。
在这类情况下,编写自己的重试循环或使用第三方库会是更合适的做法。下面我们来看几种实现方式。
基于 Try/Except 的重试循环
最简单的自定义重试形式,是使用带 try/except 代码块的循环。这让您能够完全控制要捕获哪些错误,以及如何响应。下面是重试逻辑循环的一个基本模式:
import requests
import time
max_retries = 3
for attempt in range(1, max_retries+1):
try:
response = requests.get(url, timeout=10)
# Check if response is successful
if response.status_code == 200:
print("Success on attempt", attempt)
break # exit loop on success
else:
print(f"Received status {response.status_code}.")
# Optionally, decide if this status should trigger a retry or not
except requests.exceptions.Timeout as e:
print("Request timed out, attempt", attempt)
# We can retry on timeouts
except requests.exceptions.RequestException as e:
print("Request failed:", e)
# Handle other exceptions (ConnectionError, etc.)
# If we reach here, it means we need to retry
if attempt < max_retries:
time.sleep(2) # wait 2 seconds before next attempt
else:
print("All retries failed.")
在这段代码中,我们最多尝试请求三次。我们显式捕获了超时异常(使用 requests.exceptions.Timeout),并将其视为可重试的情况。我们还捕获了一个通用的 RequestException,用于覆盖连接错误等其他问题。
每次失败之后,如果重试次数尚未用尽,我们会固定等待两秒再进行下一次尝试。
带抖动的指数退避
在实现自定义重试时,使用指数退避算法是一种推荐做法。与其在各次尝试之间固定等待一段时间,不如从一个较小的延迟开始,并在每次重试时按指数增长。这样可以减轻服务器的负担,避免大量重试请求同时涌入造成的“惊群效应”。
加入抖动意味着为这些延迟值加入一定的随机性。当多个客户端或线程同时重试时,抖动很有帮助,它能让各次重试的时间错开,避免所有请求再次同时访问服务器。
下面展示了如何生成一组带有可选抖动的退避延迟列表:
import math, random
def make_backoff_delays(base_delay, factor, max_retries, jitter=False):
delays = []
for n in range(max_retries):
# Exponential backoff: factor^(n) * base_delay
delay = (factor ** n) * base_delay
if jitter:
# random jitter between 0.5x and 1.5x of delay
delay = delay * random.uniform(0.5, 1.5)
delays.append(delay)
return delays
# Example: 5 retries, base 1s, factor 2
print(make_backoff_delays(base_delay=1, factor=2, max_retries=5, jitter=True))
运行上述代码会输出类似 [1.2, 2.1, 4.3, 8.5, 16.2]的一组数值(由于存在随机性,具体数值会有所不同)。总体思路是:等待时间随着每次尝试逐渐变长。使用指数退避可以避免压垮服务器,这是一种被广泛推荐的做法,优于可能使服务器过载的固定延迟方式。
使用 tenacity 库实现重试
如果您不想从零开始编写重试循环,可以借助 tenacity 库,这是一个用于在 Python 中实现重试的强大第三方包。
要使用 tenacity,首先需要安装它(例如执行 pip install tenacity)。然后,您可以将它的 @retry 装饰器应用到执行请求的函数上。例如:
from tenacity import retry, stop_after_attempt, wait_exponential
# Configure the retry decorator
@retry(stop=stop_after_attempt(4), wait=wait_exponential(multiplier=1, min=2, max=10))
def fetch_with_retry(url):
return requests.get(url, timeout=5)
在这段代码中,@retry 最多会将该函数重试四次(stop_after_attempt(4) 表示最多尝试四次),并在各次尝试之间使用指数等待。这里的 wait_exponential 配置从两秒延迟开始,每次翻倍(重试之间的等待时间最多为 10 秒)。
何时应优先选用自定义逻辑而非 HTTPAdapter
在内置的 HTTPAdapter 重试机制和自定义方法(包括 tenacity)都可供选择的情况下,您可能会想该如何取舍。以下是一些需要考虑的因素:
- 复杂条件
如果需要根据复杂条件进行重试,例如前面讨论过的 200 OK 响应内容、或是在执行某种修复操作(比如刷新 OAuth 令牌)之后再重试,这时就需要用到自定义逻辑了。
- 每次尝试执行不同操作
有时您可能希望在每次重试时改变操作方式。例如,第二次失败后,可以切换到另一个 URL 或调整请求参数。
- 集成与日志记录
如果您需要在每次重试时进行详细的日志记录、指标采集,或与系统其他部分集成(比如记录每次尝试,或在失败 X 次后通知其他服务),自定义方案能为您提供这种控制能力。
- 特定异常处理
虽然 urllib3 的 Retry 会自动处理一些底层异常(例如连接错误),但您可能仍希望显式处理其他异常。
- 运行环境
在某些环境中(例如异步框架,或使用 aiohttp 而非 requests 时),HTTPAdapter 方案并不适用。Tenacity 或手动重试逻辑可以针对这些场景做相应调整(例如使用异步等待)。
处理 429 和 503 等 HTTP 状态码
在重试场景中,某些 HTTP 状态码值得特别关注,其中两种常见的是 429 Too Many Requests 和 503 Service Unavailable:
- 429(请求过多)
该状态码表示服务器正在对您进行速率限制,也就是说您在短时间内发送了过多请求。许多服务器会在 429 响应中附带 Retry-After 请求头,告知您需要等待多少秒后再重试。
您的重试逻辑应检查这一点。例如,收到 429 响应时,查看 response.headers.get("Retry-After");如果该字段存在且值为“30”,则应等待 30 秒后再进行下一次尝试。
- 503(服务不可用)
503 通常表示服务器暂时无法处理请求(服务器过载或正在维护)。与 429 类似,如果服务器预计稍后恢复可用,响应中也可能带有 Retry-After 请求头。此时应按照指定时长暂停重试。
对于 429 和 503(以及 502 Bad Gateway、504 Gateway Timeout 等类似错误),只要配合退避策略并遵循服务器的信号,重试就能发挥作用。务必避免对这些响应进行高频快速重试。使用 Retry-After 请求头是一种良好实践——如果服务器提示“60 秒后再试”,就应当遵循。
重试 Python 请求时轮换 IP,告别 403 和 429 封锁
获取住宅代理结合代理与反封锁策略重试 Python 请求
当您大规模抓取网站或调用 API 时,可能会遇到简单重试无法解决的封锁或 CAPTCHA。这时就需要用到住宅代理。使用住宅代理意味着将您的 HTTP 请求通过另一台服务器(或一组服务器)转发,使目标网站看到的是不同的 IP 地址。
为什么代理对重试请求很重要
设想这样一个场景:您在短时间内发送了多次请求,随后开始收到 403 Forbidden 或 429 Too Many Requests 响应,这可能意味着您的 IP 已被服务器标记或临时封禁。
如果仅用同一个 IP 重试,很可能会持续被封锁(甚至使情况恶化)。而在重试时切换到不同的代理 IP,下一次尝试可能会成功,因为服务器会将其视为新的请求来源。简而言之,代理服务器可以为您提供一次全新的身份。
代理在访问受地域限制的内容时也很有用。如果某个网站只对特定地区提供服务,就需要使用该允许地区的代理。在重试场景中,网络爬取领域常见的策略是:如果请求因 IP 被封而失败,就轮换到下一个代理再试一次。
由于每次请求不再来自同一个地址,这种方式能提高多次尝试的整体请求成功率。
在使用代理时调整重试逻辑
将代理整合进重试逻辑需要做一些调整。如果您使用的是 HTTPAdapter 方案,可以通过 proxies 参数在 Session 或单次请求上指定代理,例如:
proxies = {
"http": "http://USERNAME:PASSWORD@proxy.server:port",
"https": "http://USERNAME:PASSWORD@proxy.server:port"
}
response = session.get(url, proxies=proxies, timeout=10)
您可以设置 Session 使用默认代理,但通常您会希望每次重试时更换代理。
在自定义重试循环中,您可以这样处理:
proxy_pool = ["proxy1.com:8000", "proxy2.com:8000", ...] # list of proxy addresses
for attempt in range(max_retries):
proxy = proxy_pool[attempt % len(proxy_pool)]
try:
resp = requests.get(url, proxies={"http": proxy, "https": proxy}, timeout=10)
if resp.status_code == 200:
return resp
elif resp.status_code in [403, 429]:
print("Got {}, switching proxy and retrying...".format(resp.status_code))
# continue to next attempt with a new proxy
else:
break # for other status codes, break out (non-retryable)
except requests.exceptions.RequestException as e:
# If a proxy itself fails (e.g., connection error), try next proxy
print("Proxy {} failed, trying next proxy...".format(proxy))
在这段伪代码中,如果收到 403 或 429,我们就认为是被封锁所致,并在下一轮迭代中切换到下一个代理。如果某个代理直接连接失败,则捕获该异常并同样切换到下一个代理。
实现基本的代理轮换
如上所示,一种基本的代理轮换策略是:维护一份代理服务器列表,并在每次重试时选用不同的代理,可以随机打乱顺序,也可以按顺序循环使用。
请务必提前测试您的代理。如果轮换列表中包含完全无法使用的代理,只会增加全部失败的重试次数。一些开发者会为每个代理单独创建一个 Session 对象,或维护一个预先配置好代理的 Session 池,以避免每次尝试都要重新构建 Session。
代理管理相关的库与工具
管理代理服务器可能会变得相当复杂,尤其是当您需要用到几十个甚至上百个 IP 时。以下是一些可以提供帮助的工具和服务:
- 代理轮换服务
像 MarsProxies 这样的公司提供代理网络,甚至还提供可自动为您轮换代理的 API。
- 开源库
有一些 Python 库,例如 requests-ip-rotator 或 scrapeops-python-proxy,可以接入 requests 来实现代理轮换。
- Scrapy 等框架
如果您的项目规模较大,像 Scrapy 这样的网络爬取框架内置了对代理中间件和轮换功能的支持。
- 自行实现
对于较简单的需求,您可能并不需要专门的库。一份代理列表加上 requests 的 proxies 参数,往往就足以应对大部分情况。
重试请求的最佳实践
我们已经介绍了实现重试的多种方式。在结束操作部分之前,来总结一些为 Python 脚本添加重试能力时的最佳实践:
- 限制总重试次数
限制重试次数非常重要。如果服务处于宕机状态,不要让程序陷入无休止的重试循环:这样只会浪费资源,甚至可能永远无法恢复。
- 使用指数退避(避免零延迟或固定延迟)
务必引入退避系数,逐步增加各次尝试之间的等待时间,以防止对服务器造成冲击。
- 为每次请求设置超时
每个请求都应设置超时(如适用,应同时包含连接超时和读取超时)。
- 记录重试过程与结果
在调试或监控时,为重试过程添加日志记录会很有帮助。
- 避免重试非幂等操作(或谨慎处理)
如前文所述,对不适合重复执行的请求进行重试时要格外谨慎。如果必须重试可能导致重复操作的 POST 或 PUT 请求,应考虑在服务器端做好安全处理重复请求的设计。
- 遵循速率限制与退避信号
重试逻辑不应忽视服务器给出的信号。如果收到 429 响应或表示需要放慢速度的请求头,应将其纳入重试逻辑中。
结论
在本文中,我们探讨了如何使用 Python 的 requests 库有效地实现 HTTP 请求重试。我们首先了解了失败发生的原因,涵盖从短暂的网络问题到服务器端错误等各种情况,并强调了智能重试的重要性:只在确有必要时重试,并避免无限循环或过于激进的重试策略。
如果 Python 不适合您的项目,可以参考我们的Golang vs Python 对比文章,了解不同语言在数据抓取与 HTTP 任务中的表现差异。此外,也可以查看我们关于HTTPX vs Requests vs AIOHTTP的文章。
想获取更多技巧与数据洞察,或想分享您在特定网站上的经验?欢迎加入我们的Discord 社区!
urllib3 中的 Retry 类可以重试 POST 请求吗?
可以,但需要进行相应配置。默认情况下,Python requests 的重试机制会排除 POST 请求,但您可以在重试策略中将其启用。
如果响应状态码为 200 但内容包含错误,该如何重试 Python 请求?
使用自定义重试逻辑来检查响应内容。200 状态码本身不会自动触发对失败请求的重试。
backoff_factor 与使用固定延迟进行重试有什么区别?
退避系数会逐步增加各次重试之间的等待时间,而固定延迟始终保持不变,可能会给服务器带来过大压力。
可以根据超时或连接错误等特定异常来决定是否重试请求吗?
可以,您可以捕获超时或连接错误等特定异常,从而有选择地对失败的请求进行重试。
在 requests 库中结合代理使用时,应如何处理重试?
每次重试时轮换 IP 以避免被封锁,并始终确保重试策略与最新的代理配置保持同步。