Python 爬虫代理中间件开发:统一处理 403、429 与自动重试机制

做数据采集的开发者,对 403 和 429 这两个状态码一定不陌生。403 表示目标服务器拒绝了你的请求,429 表示请求频率过高触发了限流。单独处理这两个错误并不难,难的是在多个爬虫项目、多个请求库之间保持一致的策略。如果每个项目都重新写一遍重试逻辑,不仅效率低,还容易遗漏关键细节。

更好的做法是把代理管理、错误处理、重试策略封装成一个统一的中间件,让业务代码只关注数据解析本身。本文从实际工程角度出发,设计一个可复用的 Python 代理中间件,统一处理 403、429 以及常见的网络异常,并给出完整的代码实现。

一、为什么需要代理中间件

在真实的采集场景中,请求失败的原因通常分为几类:目标网站返回 403 拒绝访问,返回 429 限流,连接超时,代理本身不可用,返回内容异常但状态码是 200。

如果让每个爬虫脚本自己处理这些情况,会出现几个问题。第一,重试策略不一致,有的用固定延时,有的用指数退避,维护成本高。第二,代理切换逻辑分散在各个脚本中,无法统一管理代理池的健康状态。第三,缺少统一的日志和监控,问题排查困难。

代理中间件的核心思路是:把所有与代理和错误处理相关的逻辑收敛到一个独立的模块中,对外暴露简洁的请求接口。业务代码只需要调用 fetch(url),中间件负责选择代理、发送请求、判断结果、处理重试、记录日志。

二、中间件的整体设计

中间件需要包含以下几个核心组件。

· 代理管理器:负责维护代理池,包括添加代理、健康检查、获取可用代理、标记代理状态。

· 重试策略:定义什么情况下重试、等待多久、最多重试几次。对于 403 和 429,应该采用不同的策略。403 通常意味着当前代理被拒绝,需要更换代理;429 意味着请求频率过高,需要等待一段时间再重试。

· 请求执行器:封装实际的 HTTP 请求,集成代理配置、超时设置、请求头管理。

· 结果处理器:判断响应是否有效,区分正常响应、需要重试的响应、需要更换代理的响应。

· 日志与统计:记录每次请求的结果,统计成功率、失败原因分布,方便后续优化。

整个流程可以描述为:业务代码调用 fetch,中间件从代理池获取一个代理,发起请求,判断响应。如果成功,返回结果并更新代理评分;如果失败,根据错误类型决定是等待重试还是切换代理重试,直到成功或达到最大重试次数。

三、核心代码实现

以下是一个完整的代理中间件实现,基于 requests 库,支持 403、429 的统一处理和自动重试。

python 复制代码
import time
import random
import logging
import threading
import requests
from dataclasses import dataclass, field
from typing import Optional, Dict, List, Callable
from urllib3.exceptions import InsecureRequestWarning

requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s'
)
logger = logging.getLogger(__name__)


@dataclass
class ProxyNode:
    """代理节点"""
    proxy: str
    success_count: int = 0
    fail_count: int = 0
    consecutive_fails: int = 0
    last_used: float = 0.0
    score: float = 100.0
    is_alive: bool = True

    @property
    def success_rate(self) -> float:
        total = self.success_count + self.fail_count
        if total == 0:
            return 0.5
        return self.success_count / total

    @property
    def is_available(self) -> bool:
        return self.is_alive and self.score >= 10


class ProxyManager:
    """代理管理器"""

    def __init__(self):
        self.proxies: List[ProxyNode] = []
        self.lock = threading.RLock()

    def add_proxy(self, proxy: str) -> None:
        with self.lock:
            for p in self.proxies:
                if p.proxy == proxy:
                    return
            self.proxies.append(ProxyNode(proxy=proxy))
            logger.info(f"添加代理: {proxy}")

    def add_proxies(self, proxies: List[str]) -> None:
        for proxy in proxies:
            self.add_proxy(proxy)

    def get_proxy(self) -> Optional[str]:
        with self.lock:
            available = [p for p in self.proxies if p.is_available]
            if not available:
                return None
            weights = [max(1, p.score) for p in available]
            total = sum(weights)
            pick = random.uniform(0, total)
            cum = 0
            for p, w in zip(available, weights):
                cum += w
                if pick <= cum:
                    p.last_used = time.time()
                    return p.proxy
            return available[-1].proxy

    def mark_success(self, proxy: str) -> None:
        with self.lock:
            node = self._find(proxy)
            if node:
                node.success_count += 1
                node.consecutive_fails = 0
                node.score = min(100, node.score + 2)

    def mark_fail(self, proxy: str, penalty: int = 10) -> None:
        with self.lock:
            node = self._find(proxy)
            if node:
                node.fail_count += 1
                node.consecutive_fails += 1
                node.score = max(0, node.score - penalty)
                if node.consecutive_fails >= 3:
                    node.is_alive = False
                    logger.warning(f"代理失效: {proxy}")

    def _find(self, proxy: str) -> Optional[ProxyNode]:
        for p in self.proxies:
            if p.proxy == proxy:
                return p
        return None


class RetryPolicy:
    """重试策略"""

    def __init__(
        self,
        max_retries: int = 3,
        base_delay: float = 1.0,
        max_delay: float = 30.0,
        backoff_factor: float = 2.0,
    ):
        self.max_retries = max_retries
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.backoff_factor = backoff_factor

    def get_delay(self, attempt: int, status_code: Optional[int] = None) -> float:
        if status_code == 429:
            delay = self.base_delay * (self.backoff_factor ** attempt)
        else:
            delay = self.base_delay * (self.backoff_factor ** attempt)
        delay = min(delay, self.max_delay)
        return delay + random.uniform(0, 0.5)


class ProxyMiddleware:
    """
    代理中间件
    统一处理代理选择、请求发送、403/429 识别、自动重试
    """

    def __init__(
        self,
        proxy_manager: Optional[ProxyManager] = None,
        retry_policy: Optional[RetryPolicy] = None,
        default_headers: Optional[Dict[str, str]] = None,
        timeout: int = 15,
    ):
        self.proxy_manager = proxy_manager or ProxyManager()
        self.retry_policy = retry_policy or RetryPolicy()
        self.default_headers = default_headers or {
            "User-Agent": (
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/120.0.0.0 Safari/537.36"
            ),
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
        }
        self.timeout = timeout
        self.session = requests.Session()
        self.stats = {
            "total": 0,
            "success": 0,
            "403": 0,
            "429": 0,
            "timeout": 0,
            "other_fail": 0,
        }
        self.lock = threading.Lock()

    def fetch(
        self,
        url: str,
        method: str = "GET",
        headers: Optional[Dict[str, str]] = None,
        params: Optional[Dict] = None,
        data: Optional[Dict] = None,
        allow_redirects: bool = True,
    ) -> Optional[requests.Response]:
        """
        发送请求,自动处理代理切换与重试
        """
        merged_headers = {**self.default_headers, **(headers or {})}
        last_exception = None

        for attempt in range(self.retry_policy.max_retries + 1):
            proxy = self.proxy_manager.get_proxy()
            if not proxy:
                logger.error("代理池为空,无法发起请求")
                return None

            proxies = {"http": proxy, "https": proxy}

            try:
                response = self.session.request(
                    method=method,
                    url=url,
                    headers=merged_headers,
                    params=params,
                    data=data,
                    proxies=proxies,
                    timeout=self.timeout,
                    allow_redirects=allow_redirects,
                    verify=False,
                )

                status = response.status_code

                if status == 200:
                    self._record("success")
                    self.proxy_manager.mark_success(proxy)
                    return response

                elif status == 403:
                    self._record("403")
                    logger.warning(f"403 拒绝访问,切换代理 (attempt {attempt + 1})")
                    self.proxy_manager.mark_fail(proxy, penalty=15)
                    continue

                elif status == 429:
                    self._record("429")
                    delay = self.retry_policy.get_delay(attempt, status_code=429)
                    logger.warning(f"429 触发限流,等待 {delay:.2f}s 后重试")
                    self.proxy_manager.mark_fail(proxy, penalty=5)
                    time.sleep(delay)
                    continue

                else:
                    self._record("other_fail")
                    logger.warning(f"状态码 {status},切换代理重试")
                    self.proxy_manager.mark_fail(proxy, penalty=5)
                    continue

            except requests.exceptions.Timeout:
                self._record("timeout")
                last_exception = "timeout"
                logger.warning(f"请求超时,切换代理 (attempt {attempt + 1})")
                self.proxy_manager.mark_fail(proxy, penalty=10)
                continue

            except requests.exceptions.ProxyError as e:
                last_exception = str(e)
                logger.warning(f"代理连接失败: {proxy},切换代理")
                self.proxy_manager.mark_fail(proxy, penalty=20)
                continue

            except requests.exceptions.RequestException as e:
                last_exception = str(e)
                logger.warning(f"请求异常: {e},切换代理")
                self.proxy_manager.mark_fail(proxy, penalty=10)
                continue

        logger.error(f"请求失败,已达最大重试次数: {url}, 最后错误: {last_exception}")
        return None

    def _record(self, key: str) -> None:
        with self.lock:
            self.stats["total"] += 1
            if key in self.stats:
                self.stats[key] += 1

    def get_stats(self) -> Dict[str, int]:
        with self.lock:
            return dict(self.stats)

    def close(self) -> None:
        self.session.close()

四、使用示例

以下示例展示如何在业务代码中使用这个中间件。

python 复制代码
def main():
    # 初始化代理管理器
    manager = ProxyManager()
    manager.add_proxies([
        "http://user:pass@proxy1.example.com:8080",
        "http://user:pass@proxy2.example.com:8080",
        "http://user:pass@proxy3.example.com:8080",
    ])

    # 初始化中间件
    middleware = ProxyMiddleware(
        proxy_manager=manager,
        retry_policy=RetryPolicy(max_retries=3, base_delay=1.5),
        timeout=15,
    )

    # 发起请求
    urls = [
        "https://httpbin.org/ip",
        "https://httpbin.org/status/403",
        "https://httpbin.org/status/429",
    ]

    for url in urls:
        response = middleware.fetch(url)
        if response:
            print(f"{url} -> {response.status_code}")
        else:
            print(f"{url} -> 请求失败")

    # 输出统计
    print("统计:", middleware.get_stats())
    middleware.close()


if __name__ == "__main__":
    main()

五、关键设计细节

1.403 和 429 的处理差异

403 通常意味着当前代理被目标网站拒绝,继续使用同一个代理重试没有意义,所以中间件在遇到 403 时直接切换代理。429 则不同,它表示请求频率过高,切换代理不一定能解决问题,更合理的做法是先等待一段时间,再尝试。中间件对 429 采用指数退避的等待策略,同时降低当前代理的评分。

2.代理评分与淘汰

每个代理都有一个初始分数 100。成功请求加 2 分,失败根据错误类型扣不同分值。连续失败 3 次的代理会被标记为不可用,不再参与分配。这样可以自动淘汰质量差的代理,把流量集中在稳定的节点上。

3.线程安全

代理池可能被多个线程同时访问。中间件使用 threading.RLock 保证代理选择、评分更新等操作的原子性。同时使用 requests.Session 复用连接,减少握手开销。

4.日志与统计

中间件记录了总请求数、成功数、403 数、429 数、超时数和其他失败数。这些数据可以帮助团队判断代理质量、目标网站的策略变化,以及是否需要调整重试参数。

六、扩展建议

1.代理健康检查

可以增加一个后台线程,定期对代理池中的节点发起健康检查,剔除失效节点。健康检查的目标可以是一个稳定的公开接口,比如 https://httpbin.org/ip

2.按目标网站区分代理策略

不同网站对代理的处理策略不同。可以为每个目标域名维护独立的代理池和重试策略,避免不同网站的规则互相干扰。

3.支持异步请求

如果采集任务需要高并发,可以把中间件的请求执行部分改为基于 aiohttphttpx 的异步实现。代理管理和重试逻辑可以保持不变,只是执行层从同步换成异步。

4.接入监控系统

把统计信息输出到 Prometheus 或其他监控系统,可以实时观察代理池的健康状态和请求成功率的变化趋势。

七、总结

代理中间件的核心价值在于把分散的错误处理逻辑集中管理。通过统一的代理选择、评分淘汰、403/429 识别和自动重试,业务代码可以保持简洁,同时获得更好的稳定性和可维护性。

本文给出的实现是一个基础版本,可以根据实际需求扩展健康检查、异步支持、监控接入等功能。关键是要理解每个设计决策背后的原因:为什么 403 要切换代理而 429 要等待,为什么要给代理评分,为什么要区分错误类型。理解了这些,才能根据自己的业务场景做出合适的调整。

相关推荐
学代码的CJY1 小时前
Python序列类型详解
python
529宝宝起名网1 小时前
用 Python 爬取古籍文献中的名字用例数据库:从二十四史到诗词文集的历史名字采集与分析
python
颜颜yan_9 小时前
ESP-IDF 鸿蒙 PC 适配全记录:打通 Python、构建工具链与 ESP32-P4 固件生成
python·华为·harmonyos
言乐69 小时前
Python加速器4跨境网络加速器
运维·服务器·开发语言·网络·python
weixin_440730509 小时前
线程02-并发串行-互斥锁-Semaphore-Event
python·thread
张小凡vip10 小时前
python--爬虫--经验积累的遇到的坑
开发语言·爬虫·python
毕业设计70310 小时前
(免费领源码) 基于微信小程序的预制菜商城的设计与实现25172-java、PHP、python、C#、小程序、大数据、单片机、网络工程等)
vue.js·python·mysql·微信小程序·pycharm·微信开发者工具·推荐算法
xifangge202510 小时前
AGENTS.md 怎么写?涵盖 Java、Python、Vue、Go 的 8 套开箱即用模板
java·vue.js·python
大草原的小灰灰11 小时前
Python基础语法
开发语言·python