自动化请求的智能重试策略:指数退避 + 熔断 + IP 轮换

做自动化请求时,最让人头疼的不是单次请求失败,而是失败之后怎么办

直接重试?可能触发风控。不重试?数据缺失。重试太频繁?IP 被封。重试太慢?效率低下。

一个成熟的重试策略需要解决三个问题:什么时候重试、等多久重试、换不换 IP。本文从这三个维度出发,讲清楚指数退避、熔断器、IP 轮换三种机制如何配合使用。

一、为什么简单重试不管用

暴力重试的问题

python 复制代码
# 最朴素的重试:失败了立刻再来

for i in range(5):

    resp = requests.get(url, proxies=proxy)

    if resp.status_code == 200:

        break

这种写法有三个致命问题:

  1. 无差别重试:404(页面不存在)和 429(限流)用同样策略,404 重试 100 次还是 404

  2. 无间隔轰炸:连续请求触发目标网站的频率风控,本来只限流 1 分钟,暴力重试后变成封 IP

  3. 不换 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 场景下:

  1. 不要频繁换 IP:一个 IP 能用就继续用,避免不必要的切换

  2. 换 IP 前先确认 IP 确实不可用:连续失败 2-3 次再换,不是失败 1 次就换

  3. 换下的 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 被永久封禁。

相关推荐
葬送的代码人生10 小时前
从 Vue 到 React:Tailwind CSS 布局 + BFF 代理实战
前端·react.js·架构
hunterandroid11 小时前
[鸿蒙从零到一] ArkUI 组件化实战:构建可复用、可组合的自定义组件
前端·华为·架构
luyingying14 小时前
我的AI NPC差点失声:多平台容错路由的完整踩坑记录
架构
施棠海14 小时前
设计稿一键变成可运行的Android页面:Pixel2XML全流程UI开发框架实战(附完整源码)
android·ui·架构
某林21214 小时前
履带小车底盘stm32F103RCT6开发
人工智能·stm32·单片机·嵌入式硬件·架构·人机交互·ros2
六bring个六14 小时前
分布式软总线认证模块架构与实现分析
分布式·架构·open harmony
XUHUOJUN14 小时前
Azure Local VM 创建与管理架构详解:从 ARM Resource Model 到本地 Hyper-V 工作负载部署
架构·azure local
zx11545015 小时前
Java 反射 (SpringBoot篇)
java·架构
她的男孩15 小时前
低代码只能做单表 CRUD?我们一行代码没写,搭了个完整进销存
java·后端·架构