Python爬虫零基础入门【第三章:Requests 静态爬取入门·第3节】稳定性第一课:超时、重试、退避(指数退避)!

🔥本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!!

全文目录:

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》

订阅后更新会优先推送,按目录学习更高效~

📌 上期回顾

在上一节《伪装与会话:Headers、Session、Cookie(合规使用)!》中,我们学习了如何使用 Session 管理会话状态,以及如何设置合适的请求头来伪装爬虫。你已经能够处理需要 Cookie 的网站了。

但是,网络世界并不完美:服务器可能宕机、网络可能抖动、响应可能超时...如果不做好异常处理,你的爬虫会频繁崩溃。

这一节,我们将学习生产级爬虫的稳定性设计,让你的爬虫能够 7×24 小时稳定运行!

🎯 本节目标

通过本节学习,你将能够:

  1. 理解超时设置的重要性和最佳实践
  2. 实现智能重试机制(指数退避算法)
  3. 区分可重试错误和不可重试错误
  4. 设计失败日志和监控指标
  5. 编写生产级 HttpClient
  6. 交付验收:编写一个能自动重试的请求器,并测试其稳定性

一、超时设置:避免程序"卡死"

1.1 为什么必须设置超时?

问题场景

python 复制代码
# ❌ 没有设置超时
response = requests.get(url)

# 如果服务器不响应,程序会永远等待下去...
# 你的爬虫任务会卡在这里,无法继续

真实案例

  • 服务器负载过高,响应缓慢
  • 网络链路故障,数据包丢失
  • 目标网站被 DDoS 攻击,无法响应

后果

  • ❌ 程序假死,无法继续执行
  • ❌ 资源占用无法释放
  • ❌ 定时任务超时失败
1.2 超时参数详解
python 复制代码
import requests

# timeout 可以是单个值(总超时)
response = requests.get(url, timeout=10)  # 10秒内必须完成

# timeout 也可以是元组(连接超时, 读取超时)
response = requests.get(url, timeout=(3, 10))
# 3秒内建立连接,10秒内读取完响应

推荐配置

python 复制代码
# 快速接口(如 API)
timeout = (3, 10)

# 普通网页
timeout = (5, 15)

# 大文件下载
timeout = (5, 60)

# 慢速网站
timeout = (10, 30)
1.3 捕获超时异常
python 复制代码
import requests
from requests.exceptions import Timeout, ConnectTimeout, ReadTimeout

def fetch_with_timeout(url):
    """带超时处理的请求"""
    try:
        response = requests.get(url, timeout=(5, 15))
        return response
        
    except ConnectTimeout:
        print(f"❌ 连接超时: {url}")
        return None
        
    except ReadTimeout:
        print(f"❌ 读取超时: {url}")
        return None
        
    except Timeout:
        print(f"❌ 请求超时: {url}")
        return None

二、重试机制:提高成功率

2.1 为什么需要重试?

临时性错误(应该重试):

  • ✅ 网络抖动(ConnectionError)
  • ✅ 服务器暂时过载(503 Service Unavailable)
  • ✅ 读取超时(ReadTimeout)
  • ✅ DNS 解析失败(临时)

永久性错误(不应重试):

  • ❌ 404 Not Found(页面不存在)
  • ❌ 403 Forbidden(无权限)
  • ❌ 401 Unauthorized(未登录)
  • ❌ 400 Bad Request(请求参数错误)
2.2 简单重试(固定延迟)
python 复制代码
import time

def fetch_with_retry(url, max_retries=3, delay=2):
    """
    带固定延迟的重试
    
    Args:
        url: 目标URL
        max_retries: 最大重试次数
        delay: 重试间隔(秒)
    """
    for attempt in range(max_retries):
        try:
            response = requests.get(url, timeout=10)
            response.raise_for_status()  # 4xx/5xx 抛出异常
            
            print(f"✅ 成功(尝试 {attempt+1}/{max_retries})")
            return response
            
        except requests.exceptions.RequestException as e:
            print(f"❌ 失败(尝试 {attempt+1}/{max_retries}): {e}")
            
            if attempt < max_retries - 1:
                print(f"⏳ 等待 {delay} 秒后重试...")
                time.sleep(delay)
            else:
                print(f"💔 放弃请求: {url}")
                return None
2.3 指数退避算法(推荐)

核心思想:重试间隔逐步增加,避免瞬时压力

json 复制代码
第1次失败 → 等待 2 秒
第2次失败 → 等待 4 秒
第3次失败 → 等待 8 秒
第4次失败 → 等待 16 秒

实现代码

python 复制代码
import time
import random

def fetch_with_backoff(url, max_retries=5, base_delay=2, max_delay=60):
    """
    带指数退避的重试
    
    Args:
        url: 目标URL
        max_retries: 最大重试次数
        base_delay: 基础延迟(秒)
        max_delay: 最大延迟(秒)
    """
    for attempt in range(max_retries):
        try:
            response = requests.get(url, timeout=10)
            response.raise_for_status()
            
            print(f"✅ 成功(尝试 {attempt+1})")
            return response
            
        except requests.exceptions.RequestException as e:
            print(f"❌ 失败(尝试 {attempt+1}/{max_retries}): {e}")
            
            if attempt < max_retries - 1:
                # 计算退避时间:2^attempt * base_delay
                delay = min(base_delay * (2 ** attempt), max_delay)
                
                # 加入随机抖动(避免"惊群效应")
                jitter = random.uniform(0, delay * 0.1)
                wait_time = delay + jitter
                
                print(f"⏳ 等待 {wait_time:.2f} 秒后重试...")
                time.sleep(wait_time)
            else:
                print(f"💔 放弃请求: {url}")
                return None

优势对比

策略 第1次 第2次 第3次 第4次 总耗时
固定延迟(2s) 2s 2s 2s 2s 8s
指数退避(2s) 2s 4s 8s 16s 30s

适用场景

  • 固定延迟:网络稳定,偶尔抖动
  • 指数退避:服务器过载,需要缓冲时间

三、错误分类与处理策略

3.1 错误类型判断
python 复制代码
from requests.exceptions import (
    RequestException,
    ConnectionError,
    Timeout,
    HTTPError,
    TooManyRedirects
)

class ErrorClassifier:
    """错误分类器"""
    
    # 可重试的 HTTP 状态码
    RETRYABLE_STATUS_CODES = {
        408,  # Request Timeout
        429,  # Too Many Requests
        500,  # Internal Server Error
        502,  # Bad Gateway
        503,  # Service Unavailable
        504,  # Gateway Timeout
    }
    
    # 不可重试的状态码
    NON_RETRYABLE_STATUS_CODES = {
        400,  # Bad Request
        401,  # Unauthorized
        403,  # Forbidden
        404,  # Not Found
        405,  # Method Not Allowed
        410,  # Gone
    }
    
    @classmethod
    def should_retry(cls, exception):
        """
        判断错误是否应该重试
        
        Args:
            exception: 异常对象
            
        Returns:
            bool: 是否应该重试
        """
        # 1. 网络相关错误 → 重试
        if isinstance(exception, (ConnectionError, Timeout)):
            return True
        
        # 2. HTTP 错误
        if isinstance(exception, HTTPError):
            response = exception.response
            if response is not None:
                status_code = response.status_code
                
                # 明确可重试的状态码
                if status_code in cls.RETRYABLE_STATUS_CODES:
                    return True
                
                # 明确不可重试的状态码
                if status_code in cls.NON_RETRYABLE_STATUS_CODES:
                    return False
        
        # 3. 重定向过多 → 不重试
        if isinstance(exception, TooManyRedirects):
            return False
        
        # 4. 其他未知错误 → 默认重试一次
        return True

# 使用示例
try:
    response = requests.get(url)
    response.raise_for_status()
except RequestException as e:
    if ErrorClassifier.should_retry(e):
        print("✅ 可重试")
    else:
        print("❌ 不应重试")
3.2 智能重试实现
python 复制代码
def smart_fetch(url, max_retries=5):
    """
    智能重试请求
    
    - 根据错误类型决定是否重试
    - 使用指数退避算法
    """
    base_delay = 2
    max_delay = 60
    
    for attempt in range(max_retries):
        try:
            response = requests.get(url, timeout=(5, 15))
            response.raise_for_status()
            
            print(f"✅ 请求成功")
            return response
            
        except RequestException as e:
            print(f"❌ 失败(尝试 {attempt+1}/{max_retries})")
            print(f"   错误类型: {type(e).__name__}")
            print(f"   错误信息: {e}")
            
            # 判断是否应该重试
            if not ErrorClassifier.should_retry(e):
                print(f"💔 错误不可重试,放弃")
                return None
            
            # 如果还有重试机会
            if attempt < max_retries - 1:
                delay = min(base_delay * (2 ** attempt), max_delay)
                jitter = random.uniform(0, delay * 0.1)
                wait_time = delay + jitter
                
                print(f"⏳ {wait_time:.2f} 秒后重试...")
                time.sleep(wait_time)
            else:
                print(f"💔 达到最大重试次数,放弃")
                return None
    
    return None

四、生产级 HttpClient(完整版)

4.1 设计要求

✅ 支持超时设置

✅ 支持智能重试(指数退避)

✅ 支持 Session 管理

✅ 支持失败日志记录

✅ 支持请求统计(成功率、平均耗时)

✅ 代码可复用

4.2 完整实现
python 复制代码
"""
生产级 HTTP 客户端 v1.0
文件名: http_client.py
"""

import requests
import time
import random
import logging
from typing import Optional, Dict, Any
from datetime import datetime
from requests.exceptions import RequestException, HTTPError

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


class HttpClient:
    """生产级 HTTP 客户端"""
    
    # 可重试的状态码
    RETRYABLE_CODES = {408, 429, 500, 502, 503, 504}
    
    def __init__(self, 
                 max_retries: int = 5,
                 base_delay: float = 2.0,
                 max_delay: float = 60.0,
                 timeout: tuple = (5, 15)):
        """
        初始化客户端
        
        Args:
            max_retries: 最大重试次数
            base_delay: 基础延迟(秒)
            max_delay: 最大延迟(秒)
            timeout: 超时设置 (连接超时, 读取超时)
        """
        self.max_retries = max_retries
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.timeout = timeout
        
        # 创建 Session
        self.session = requests.Session()
        
        # 设置默认请求头
        self.session.headers.update({
            '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'
        })
        
        # 统计信息
        self.stats = {
            'total_requests': 0,
            'successful_requests': 0,
            'failed_requests': 0,
            'total_retries': 0,
            'total_time': 0.0
        }
    
    def get(self, url: str, **kwargs) -> Optional[requests.Response]:
        """GET 请求"""
        return self._request('GET', url, **kwargs)
    
    def post(self, url: str, **kwargs) -> Optional[requests.Response]:
        """POST 请求"""
        return self._request('POST', url, **kwargs)
    
    def _request(self, 
                 method: str, 
                 url: str, 
                 **kwargs) -> Optional[requests.Response]:
        """
        核心请求方法(带重试)
        
        Args:
            method: 请求方法 (GET/POST)
            url: 目标URL
            **kwargs: 传递给 requests 的参数
            
        Returns:
            Response 对象,失败返回 None
        """
        self.stats['total_requests'] += 1
        start_time = time.time()
        
        # 合并超时设置
        if 'timeout' not in kwargs:
            kwargs['timeout'] = self.timeout
        
        for attempt in range(self.max_retries):
            try:
                # 发起请求
                if method == 'GET':
                    response = self.session.get(url, **kwargs)
                else:
                    response = self.session.post(url, **kwargs)
                
                # 检查状态码
                response.raise_for_status()
                
                # 成功
                elapsed = time.time() - start_time
                self.stats['successful_requests'] += 1
                self.stats['total_time'] += elapsed
                
                if attempt > 0:
                    self.stats['total_retries'] += attempt
                    logger.info(f"✅ 请求成功(重试 {attempt} 次): {url}")
                
                return response
                
            except RequestException as e:
                elapsed = time.time() - start_time
                
                # 记录日志
                logger.warning(
                    f"❌ 请求失败 (尝试 {attempt+1}/{self.max_retries}): {url}\n"
                    f"   错误: {type(e).__name__}: {e}"
                )
                
                # 判断是否应该重试
                should_retry = self._should_retry(e)
                
                if not should_retry:
                    logger.error(f"💔 错误不可重试,放弃: {url}")
                    self.stats['failed_requests'] += 1
                    return None
                
                # 如果还有重试机会
                if attempt < self.max_retries - 1:
                    wait_time = self._calculate_backoff(attempt)
                    logger.info(f"⏳ 等待 {wait_time:.2f} 秒后重试...")
                    time.sleep(wait_time)
                else:
                    logger.error(f"💔 达到最大重试次数,放弃: {url}")
                    self.stats['failed_requests'] += 1
                    return None
        
        return None
    
    def _should_retry(self, exception: RequestException) -> bool:
        """判断错误是否应该重试"""
        from requests.exceptions import (
            ConnectionError, Timeout, HTTPError, TooManyRedirects
        )
        
        # 网络错误 → 重试
        if isinstance(exception, (ConnectionError, Timeout)):
            return True
        
        # HTTP 错误
        if isinstance(exception, HTTPError):
            if hasattr(exception, 'response') and exception.response is not None:
                status_code = exception.response.status_code
                return status_code in self.RETRYABLE_CODES
        
        # 重定向过多 → 不重试
        if isinstance(exception, TooManyRedirects):
            return False
        
        # 默认重试
        return True
    
    def _calculate_backoff(self, attempt: int) -> float:
        """计算退避时间(指数退避 + 随机抖动)"""
        delay = min(self.base_delay * (2 ** attempt), self.max_delay)
        jitter = random.uniform(0, delay * 0.1)
        return delay + jitter
    
    def get_stats(self) -> Dict[str, Any]:
        """获取统计信息"""
        total = self.stats['total_requests']
        success = self.stats['successful_requests']
        
        stats = self.stats.copy()
        
        if total > 0:
            stats['success_rate'] = f"{success/total*100:.2f}%"
        
        if success > 0:
            avg_time = self.stats['total_time'] / success
            stats['avg_response_time'] = f"{avg_time:.3f}s"
        
        return stats
    
    def print_stats(self):
        """打印统计信息"""
        stats = self.get_stats()
        
        print("\n" + "=" * 60)
        print("📊 请求统计")
        print("=" * 60)
        print(f"总请求数: {stats['total_requests']}")
        print(f"成功: {stats['successful_requests']}")
        print(f"失败: {stats['failed_requests']}")
        print(f"总重试次数: {stats['total_retries']}")
        print(f"成功率: {stats.get('success_rate', 'N/A')}")
        print(f"平均响应时间: {stats.get('avg_response_time', 'N/A')}")
        print("=" * 60 + "\n")
    
    def close(self):
        """关闭 Session"""
        self.session.close()


# ========== 使用示例 ==========

if __name__ == "__main__":
    # 创建客户端
    client = HttpClient(max_retries=3, base_delay=1.0)
    
    # 测试URL列表
    test_urls = [
        "https://httpbin.org/get",           # 正常请求
        "https://httpbin.org/status/500",    # 服务器错误(会重试)
        "https://httpbin.org/status/404",    # 404错误(不重试)
        "https://httpbin.org/delay/3",       # 延迟响应
        "https://invalid-domain-12345.com",  # 连接失败(会重试)
    ]
    
    print("🚀 开始测试...")
    
    for url in test_urls:
        print(f"\n{'='*60}")
        print(f"测试: {url}")
        print(f"{'='*60}")
        
        response = client.get(url)
        
        if response:
            print(f"✅ 状态码: {response.status_code}")
        else:
            print(f"❌ 请求失败")
        
        time.sleep(1)  # 测试间隔
    
    # 打印统计
    client.print_stats()
    
    # 关闭客户端
    client.close()

五、失败日志设计

5.1 日志级别规范
python 复制代码
import logging

# 配置多级别日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
    handlers=[
        logging.FileHandler('spider.log', encoding='utf-8'),
        logging.StreamHandler()
    ]
)

logger = logging.getLogger(__name__)

# 使用示例
logger.debug("调试信息:请求参数详情")
logger.info("普通信息:开始抓取")
logger.warning("警告信息:重试第3次")
logger.error("错误信息:请求失败")
logger.critical("严重错误:程序崩溃")
5.2 结构化日志
python 复制代码
import json

def log_request_failure(url, error, attempt, max_retries):
    """记录请求失败的详细信息"""
    log_data = {
        'timestamp': datetime.now().isoformat(),
        'url': url,
        'error_type': type(error).__name__,
        'error_message': str(error),
        'attempt': attempt,
        'max_retries': max_retries,
        '
    }
    
    logger.error(json.dumps(log_data, ensure_ascii=False))

六、本节小结

本节我们学习了爬虫稳定性的核心技能:

超时设置 :避免程序卡死,合理配置连接和读取超时

智能重试 :指数退避算法,逐步增加重试间隔

错误分类 :区分可重试和不可重试的错误

生产级客户端 :HttpClient 封装,统计监控功能

日志设计:结构化记录,便于问题排查

核心原则

  • 超时必设:所有请求都要设置超时
  • 智能重试:不是所有错误都该重试
  • 指数退避:避免瞬时压力过大
  • 统计监控:记录成功率和响应时间

七、课后作业(必做,验收进入下一节)

任务1:测试 HttpClient

使用本节的 HttpClient 类,测试以下场景并截图:

python 复制代码
client = HttpClient(max_retries=3, base_delay=1.0)

# 场景1:正常请求
client.get("https://httpbin.org/get")

# 场景2:会重试的错误
client.get("https://httpbin.org/status/503")

# 场景3:不会重试的错误
client.get("https://httpbin.org/status/404")

# 打印统计
client.print_stats()
任务2:扩展功能

HttpClient 添加以下方法:

python 复制代码
def set_headers(self, headers):
    """批量设置请求头"""
    pass

def enable_proxy(self, proxy_url):
    """启用代理"""
    pass

def save_failed_urls(self, filename):
    """保存所有失败的URL到文件"""
    pass
任务3:真实场景压测

选择一个稳定的网站,批量请求 20 个 URL:

  1. 记录总耗时
  2. 统计成功率
  3. 观察重试情况
  4. 分析日志中的错误类型

验收方式:在留言区提交:

  • 测试运行的完整日志截图
  • 扩展功能代码
  • 压测统计报告
  • 学习心得和问题

🔮下期预告

下一节《列表页→详情页:两段式采集(90%项目都这样)》,我们将学习:

  • 交付:抓取 1 个列表页的 N 条详情链接并逐个采集
  • 大纲:队列、去重集合、失败重试;"先小样本跑通再扩大"

💬 稳定性是爬虫的生命线!重试机制让你的程序更可靠!

记住:好的爬虫不是从不出错,而是出错后能优雅地恢复。工程师要为最坏的情况做好准备!

🌟文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。

相关推荐
啊阿狸不会拉杆1 小时前
《机器学习》第 7 章 - 神经网络与深度学习
人工智能·python·深度学习·神经网络·机器学习·ai·ml
没有bug.的程序员2 小时前
Spring Boot 启动原理:从 @SpringBootApplication 到自动配置深度解析
java·spring boot·后端·python·springboot·application
学掌门2 小时前
从数据库到可视化性能,5个大数据分析工具测评,python只排倒数
数据库·python·数据分析
小二·2 小时前
Python Web 开发进阶实战:联邦学习平台 —— 在 Flask + Vue 中构建隐私保护的分布式 AI 训练系统
前端·python·flask
kuiini2 小时前
scikit-learn 常用算法与评估方法【Plan 7】
python·算法·scikit-learn
SunnyRivers2 小时前
Python 包和项目管理工具uv核心亮点详解
python·uv·亮点
充值修改昵称2 小时前
数据结构基础:堆高效数据结构全面解析
数据结构·python·算法
人工智能培训2 小时前
数字孪生技术:工程应用图景与效益评估
人工智能·python·算法·大模型应用工程师·大模型工程师证书
小北方城市网2 小时前
MyBatis 进阶实战:插件开发与性能优化
数据库·redis·python·elasticsearch·缓存·性能优化·mybatis