做自动化请求时,最让人头疼的不是单次请求失败,而是失败之后怎么办。
直接重试?可能触发风控。不重试?数据缺失。重试太频繁?IP 被封。重试太慢?效率低下。
一个成熟的重试策略需要解决三个问题:什么时候重试、等多久重试、换不换 IP。本文从这三个维度出发,讲清楚指数退避、熔断器、IP 轮换三种机制如何配合使用。
一、为什么简单重试不管用
暴力重试的问题
python
# 最朴素的重试:失败了立刻再来
for i in range(5):
resp = requests.get(url, proxies=proxy)
if resp.status_code == 200:
break
这种写法有三个致命问题:
-
无差别重试:404(页面不存在)和 429(限流)用同样策略,404 重试 100 次还是 404
-
无间隔轰炸:连续请求触发目标网站的频率风控,本来只限流 1 分钟,暴力重试后变成封 IP
-
不换 IP:如果当前 IP 已经被限流,用同一个 IP 重试永远不会成功
正确的重试决策树
请求失败
├── 4xx 错误(客户端错误)
│ ├── 429 限流 → 等待 + 换 IP 重试
│ ├── 403 禁止 → 换 IP 重试
│ ├── 404 不存在 → 不重试(资源确实不存在)
│ └── 400 参数错误 → 不重试(请求本身有问题)
├── 5xx 错误(服务端错误)→ 等待 + 同 IP 重试
└── 网络超时/连接失败 → 换 IP 重试
核心原则:区分错误类型,只对有恢复可能的错误重试。
二、指数退避:等多久重试
原理
指数退避的核心思想:每次重试的等待时间按指数增长,避免在短时间内密集请求。
第1次重试 → 等待 1s
第2次重试 → 等待 2s
第3次重试 → 等待 4s
第4次重试 → 等待 8s
第5次重试 → 等待 16s
实现代码
python
import time
import random
import requests
def request_with_backoff(url, proxy, max_retries=5):
base_delay = 1 # 基础延迟(秒)
for attempt in range(max_retries):
try:
resp = requests.get(url, proxies=proxy, timeout=10)
# 成功
if resp.status_code == 200:
return resp
# 限流:等待 + 换 IP
if resp.status_code == 429:
delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
print(f" 限流,第{attempt+1}次重试,等待 {delay:.1f}s")
time.sleep(delay)
proxy = rotate_ip() # 换 IP
continue
# 403:换 IP 重试
if resp.status_code == 403:
print(f" 被禁止,换 IP 重试")
proxy = rotate_ip()
continue
# 404/400:不重试
if resp.status_code in (400, 404):
print(f" {resp.status_code},不重试")
return resp
# 其他 5xx:等待重试
if resp.status_code >= 500:
delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
print(f" 服务端{resp.status_code},等待 {delay:.1f}s 重试")
time.sleep(delay)
continue
except requests.exceptions.Timeout:
print(f" 超时,换 IP 重试")
proxy = rotate_ip()
continue
except requests.exceptions.ConnectionError:
print(f" 连接失败,换 IP 重试")
proxy = rotate_ip()
continue
return None # 重试耗尽
为什么要加随机抖动
注意代码中的 random.uniform(0, 1),这叫抖动(jitter)。
如果不加抖动,多个线程在同一时刻被限流后,会在完全相同的时间点同时重试,形成"重试风暴"。加抖动后,每个线程的重试时间略有错开,避免同步重试导致的二次限流。
退避上限
指数增长很快会变得不可接受(第 10 次重试要等 512 秒),实际使用中需要设置上限:
python
delay = min(base_delay * (2 ** attempt), max_delay) # max_delay = 60s
三、熔断器:什么时候停止重试
原理
熔断器(Circuit Breaker)的核心思想:当失败率超过阈值时,停止所有请求一段时间,而不是继续重试加重负载。
三种状态:
python
关闭(Closed)→ 正常请求
↓ 失败率 > 50%
打开(Open)→ 停止请求,等待冷却时间
↓ 冷却时间结束
半开(Half-Open)→ 放行少量请求试探
↓ 成功 → 关闭(恢复正常)
↓ 失败 → 打开(继续等待)
```
### 实现代码
```python
import time
from collections import deque
class CircuitBreaker:
def __init__(self, failure_threshold=5, success_threshold=3,
cooldown=30, window_size=20):
self.failure_threshold = failure_threshold # 触发熔断的失败次数
self.success_threshold = success_threshold # 半开状态下恢复需要的成功次数
self.cooldown = cooldown # 熔断冷却时间(秒)
self.window_size = window_size # 滑动窗口大小
self.state = "closed" # closed / open / half_open
self.recent_results = deque(maxlen=window_size)
self.opened_at = 0
self.half_open_successes = 0
def can_request(self):
if self.state == "closed":
return True
elif self.state == "open":
if time.time() - self.opened_at >= self.cooldown:
self.state = "half_open"
self.half_open_successes = 0
return True
return False
elif self.state == "half_open":
return True # 半开状态放行请求
def record_result(self, success):
if self.state == "half_open":
if success:
self.half_open_successes += 1
if self.half_open_successes >= self.success_threshold:
self.state = "closed"
self.recent_results.clear()
else:
self.state = "open"
self.opened_at = time.time()
else:
self.recent_results.append(success)
if len(self.recent_results) >= self.window_size:
failure_count = sum(1 for r in self.recent_results if not r)
failure_rate = failure_count / len(self.recent_results)
if failure_rate >= 0.5: # 失败率超过 50%
self.state = "open"
self.opened_at = time.time()
print(f" [熔断器] 失败率 {failure_rate:.0%},熔断打开,冷却 {self.cooldown}s")
熔断器的价值
假设你有 100 个请求要发,目标网站临时限流:
-
无熔断:100 个请求全部失败,每个重试 5 次 = 500 次无效请求,IP 被永久封禁
-
有熔断:前 10 个失败触发熔断,暂停 30 秒,30 秒后放行 3 个试探请求,成功后恢复
四、IP 轮换:换不换 IP
轮换触发条件

轮换策略
python
import itertools
class IPRotator:
def __init__(self, ip_list):
self.ip_pool = itertools.cycle(ip_list)
self.failed_ips = set()
def get_next(self):
"""获取下一个可用 IP"""
for _ in range(len(self.failed_ips) + 1):
ip = next(self.ip_pool)
if ip not in self.failed_ips:
return ip
# 所有 IP 都失败,重置失败集合
self.failed_ips.clear()
return next(self.ip_pool)
def mark_failed(self, ip):
"""标记 IP 为失败"""
self.failed_ips.add(ip)
静态 IP vs 动态 IP 的轮换差异
这里有一个关键认知:静态 ISP IP 的轮换策略和动态住宅 IP 完全不同。
动态住宅 IP:每次请求自动换 IP,轮换是"免费的",失败成本极低。
静态 ISP IP:IP 是固定的,不能自动切换。轮换意味着切换到池中另一条静态 IP,每条 IP 都有成本。因此静态 IP 场景下:
-
不要频繁换 IP:一个 IP 能用就继续用,避免不必要的切换
-
换 IP 前先确认 IP 确实不可用:连续失败 2-3 次再换,不是失败 1 次就换
-
换下的 IP 不要永久弃用:静态 IP 有限,过一段时间可以重新启用
如果你使用 LinkStatic 这类按 IP 计费的服务商,建议购买 3-5 条 IP 组成小池子,配合上面的轮换策略,在稳定性和成本之间取得平衡。相比按流量计费的动态住宅 IP,静态 IP 的固定成本更可控,且每条 IP 的 ASN 和纯净度可以提前验证,不会遇到"换了 IP 发现新 IP 带案底"的问题。
五、三者整合
把指数退避、熔断器、IP 轮换整合到一起:
python
def smart_request(url, rotator, max_retries=5):
breaker = CircuitBreaker(failure_threshold=5, cooldown=30)
current_ip = rotator.get_next()
proxy = {"https": f"socks5h://user:pass@{current_ip}:1080"}
for attempt in range(max_retries):
# 1. 熔断器检查
if not breaker.can_request():
print(" 熔断中,等待恢复...")
time.sleep(5)
continue
# 2. 发送请求
try:
resp = requests.get(url, proxies=proxy, timeout=10)
success = resp.status_code == 200
breaker.record_result(success)
if success:
return resp
# 3. 根据状态码决定策略
if resp.status_code in (429, 403):
rotator.mark_failed(current_ip)
current_ip = rotator.get_next()
proxy = {"https": f"socks5h://user:pass@{current_ip}:1080"}
delay = min(2 ** attempt, 30) + random.uniform(0, 1)
time.sleep(delay)
elif resp.status_code >= 500:
delay = min(2 ** attempt, 30)
time.sleep(delay)
else:
return resp # 4xx 不重试
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
breaker.record_result(False)
rotator.mark_failed(current_ip)
current_ip = rotator.get_next()
proxy = {"https": f"socks5h://user:pass@{current_ip}:1080"}
time.sleep(1)
return None
六、总结
核心原则: 重试不是"失败了再来一次",而是一套"判断失败原因 → 选择重试策略 → 控制重试节奏 → 适时切换资源"的完整决策系统。把这套机制做对,1000 次请求的成功率可以从 85% 提升到 98% 以上,同时避免 IP 被永久封禁。