作者:互联网 时间: 2026-07-22 08:52:53
做自动化请求时,最让人头疼的不是单次请求失败,而是失败之后怎么办。
直接重试?可能触发风控。不重试?数据缺失。重试太频繁?IP 被封。重试太慢?效率低下。
一个成熟的重试策略需要解决三个问题:什么时候重试、等多久重试、换不换 IP。本文从这三个维度出发,讲清楚指数退避、熔断器、IP 轮换三种机制如何配合使用。
# 最朴素的重试:失败了立刻再来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
import timeimport randomimport requestsdef 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# 限流:等待 + 换 IPif 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() # 换 IPcontinue# 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)continueexcept requests.exceptions.Timeout:print(f" 超时,换 IP 重试")proxy = rotate_ip()continueexcept requests.exceptions.ConnectionError:print(f" 连接失败,换 IP 重试")proxy = rotate_ip()continuereturn None # 重试耗尽
注意代码中的 random.uniform(0, 1),这叫抖动(jitter)。
如果不加抖动,多个线程在同一时刻被限流后,会在完全相同的时间点同时重试,形成"重试风暴"。加抖动后,每个线程的重试时间略有错开,避免同步重试导致的二次限流。
指数增长很快会变得不可接受(第 10 次重试要等 512 秒),实际使用中需要设置上限:
delay = min(base_delay * (2 ** attempt), max_delay) # max_delay = 60s
熔断器(Circuit Breaker)的核心思想:当失败率超过阈值时,停止所有请求一段时间,而不是继续重试加重负载。
三种状态:
关闭(Closed)→ 正常请求↓ 失败率 > 50%打开(Open)→ 停止请求,等待冷却时间↓ 冷却时间结束半开(Half-Open)→ 放行少量请求试探↓ 成功 → 关闭(恢复正常)↓ 失败 → 打开(继续等待)```### 实现代码```pythonimport timefrom collections import dequeclass 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_openself.recent_results = deque(maxlen=window_size)self.opened_at = 0self.half_open_successes = 0def can_request(self):if self.state == "closed":return Trueelif self.state == "open":if time.time() - self.opened_at >= self.cooldown:self.state = "half_open"self.half_open_successes = 0return Truereturn Falseelif self.state == "half_open":return True # 半开状态放行请求def record_result(self, success):if self.state == "half_open":if success:self.half_open_successes += 1if 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 个试探请求,成功后恢复
import itertoolsclass 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)
这里有一个关键认知:静态 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 轮换整合到一起:
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 == 200breaker.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 被永久封禁。