4.4 重试、超时与错误处理 本节摘要:失败分两类:可重试的(超时、5xx、断连)与永久的(404、逻辑错误)。框架的重试中间件、超时配置与 errback 回调各管一段,本节把它们拼成一套完整的失败策略,并带上测试思路——让失败件有去处、有记录、有补偿,而不是散落在日志里自生自灭。 2.2 节说过"失败没有回程":默认重试耗尽后,响应不会交到解析回调手里。本节把这条失联通道完整走一遍——它是本章的收尾,也是 Spider 车间之外你最后能干预旅程的地方。 失败的分类学 先分类,再谈处理。
本节摘要:失败分两类:可重试的(超时、5xx、断连)与永久的(404、逻辑错误)。框架的重试中间件、超时配置与 errback 回调各管一段,本节把它们拼成一套完整的失败策略,并带上测试思路——让失败件有去处、有记录、有补偿,而不是散落在日志里自生自灭。
2.2 节说过"失败没有回程":默认重试耗尽后,响应不会交到解析回调手里。本节把这条失联通道完整走一遍——它是本章的收尾,也是 Spider 车间之外你最后能干预旅程的地方。
先分类,再谈处理。工程上失败只有两类:
| 类别 | 典型表现 | 正确处置 |
|---|---|---|
| 暂时性失败 | 超时、连接重置、5xx、429 | 指数退避重试,重试耗尽进失联通道 |
| 永久性失败 | 404、410、参数错误 | 不重试,直接登记后放弃 |
框架的重试中间件默认按这个思路工作:RETRY_HTTP_CODES 清单里的状态码才触发重试,404 不在其中。把 429 加进清单(3.2 节做过)是常见微调,但切记配合退避,否则重试本身会加剧限流。
超时有两个刻度。单请求超时写在配置里,也可按票覆写:
# settings 配置模块:全局超时 DOWNLOAD_TIMEOUT = 20 # Spider 里对慢页面单独放宽 yield scrapy.Request( slow_url, callback=self.parse_report, meta={"download_timeout": 60}, # 行李舱里的保留键:本票单独 60 秒 )
全站刻度则要靠"最长等待":一个响应等 60 秒才回,从吞吐角度等价于浪费了一路并发。慢站多时,宁可缩短全局超时让重试机制兜底,也别让个别慢站拖垮整体节奏。
errback 在重试耗尽后触发,收到的是 Failure 对象——它不是 Response,包裹着异常与原请求:
def parse(self, response): for href in response.css("a.doc::attr(href)").getall(): yield response.follow(href, callback=self.parse_doc, errback=self.on_fail, meta={"depth": response.meta.get("depth", 0)}) def on_fail(self, failure): # 三件套:类型、原地址、原票的行李舱 req = failure.request yield { "failed_type": type(failure.value).__name__, # TimeoutError 等 "failed_url": req.url, "context": req.meta, # 原票行李舱,可判重试来源 }
把失败件 yield 成 Item 是个实用模式:它走正常管道入库,你便能在数据库里对失败件做二次清洗与补抓——失败不再只是日志里的一行字,而是可查询的数据。注意登记 Item 与业务 Item 要在管道里分流,别让失败记录混进业务表。
重试不是越多越好。判定"放弃"的三个依据:技术上,重试次数耗尽且失败类型不变(连续超时的地址大概率还会超时);业务上,该地址的最新性已无价值(价格快照过了当天就作废);礼貌上,对方持续拒绝而你还反复敲门,姿态已经失当。三个依据命中其一就该放弃,把并发预算让给还有价值的地址。
放弃不等于遗忘——失败登记(errback 的产出)就是为二次处理留的口子:站点恢复后拿失败清单重跑一轮,成本远低于全量重爬。把"首次采集"与"失败补抓"当成两个任务来设计,失败处理就从兜底逻辑升级成了工作流。
统计计数器是失败的账本,收工时必读:
'downloader/exception_count': 14, 'downloader/exception_type_count/twisted.internet.error.TimeoutError': 11, 'downloader/exception_type_count/twisted.internet.error.ConnectionRefusedError': 3, 'httperror/response_count': 9, 'retry/count': 22, 'retry/max_reached': 6, 'finish_reason': 'finished'
解读路径:exception_type_count 按异常类型分账——超时集中说明对端慢或代理劣;retry/max_reached 非零说明有票重试到底仍失败;httperror/response_count 是非 2xx 响应的总量。若 finish_reason 出现 cancelled 或 shutdown,说明旅程被外部打断,数据完整性要打折评估。
失败路径和正常路径一样需要测试。框架自带 contracts 机制,用文档字符串声明回调的输入输出契约,做轻量回归:
def parse_doc(self, response): """解析文档详情页 @url https://books.example.com/doc/1 @returns items 1 1 @returns requests 0 0 """ yield {"title": response.css("h1::text").get(default="")}
@url 指定测试地址,@returns 声明期望产出的 Item 与请求数量区间。运行 scrapy check 即执行契约校验——选择器被站点改版击穿时,它会先于线上告警红脸。更重的测试(mock 响应、录放回放)用 pytest 加框架的测试工具组合,思路一致:用真实响应样本固化解析行为,站点改版跑一遍测试就知道伤在哪。
💡 关键直觉:失败处理的目标不是消灭失败,而是让每张失联的票都有记录、有归类、有再上路的路径。零失败率的报表值得怀疑——那多半是把失败藏进了没人看的日志。
隧道走完,票到站了。下一章处理到站后的疑难件:要登录的站、要渲染的页,以及陌生站点的采集策略。