Python 现代开发学习笔记

(十四)按失败动作重试 GET

1. 从一次失败推导下一次尝试

第一次读取返回 503,稍后再次读取可能成功;路径写错得到 404,则应该检查路径。页面已经返回 200,但书名节点缺失,也应该检查页面结构,而不是立即把相同内容再下载几次。

观察到的失败 本章采取的动作
429、500、502、503、504 记录尝试次数,等待后再次 GET
连接失败、读取超时、远端协议中断 在尝试次数内重新读取
404 等其他失败状态 将 URL 和状态反馈到任务结果
HTML 结构或书籍字段错误 保存具体解析错误,任务留待检查后恢复
调用者取消或整批时间到期 继续取消、收尾与退出
# 项目文件:crawler_project/src/catalogcrawl/transport.py
# 这里只重试读取 GET;状态失败与网络等待失败保留 URL 和尝试次数。
from asyncio import sleep
import logging
import math

import httpx

from .errors import CrawlError

logger = logging.getLogger(__name__)
RETRY_STATUS = {429, 500, 502, 503, 504}


def retry_delay(response, attempt):
    fallback = 0.05 * 2 ** (attempt - 1)
    value = response.headers.get("Retry-After") if response is not None else None
    try:
        seconds = float(value)
    except (TypeError, ValueError):
        return fallback
    return seconds if math.isfinite(seconds) and seconds >= 0 else fallback


async def get_response(client, url, gate, attempts=3):
    if attempts < 1:
        raise ValueError("请求尝试次数应至少为 1")
    for attempt in range(1, attempts + 1):
        await gate.wait()
        response = None
        try:
            response = await client.get(url)
            response.raise_for_status()
            return response
        except httpx.HTTPStatusError as cause:
            retryable = cause.response.status_code in RETRY_STATUS
            failure = cause
        except (
            httpx.ConnectError,
            httpx.ReadTimeout,
            httpx.RemoteProtocolError,
        ) as cause:
            retryable, failure = True, cause
        except httpx.RequestError as cause:
            raise CrawlError(f"{url}:{type(cause).__name__} {cause}") from cause
        if not retryable or attempt == attempts:
            raise CrawlError(f"{url}:第 {attempt} 次读取失败,{failure}") from failure
        delay = retry_delay(response, attempt)
        logger.warning("准备重试 url=%s attempt=%s wait=%.3f", url, attempt, delay)
        # 退避让出循环;下一次尝试仍经过共享的 StartGate。
        await sleep(delay)

没有秒数提示时,等待按 0.05 × 2^(attempt−1) 计算:第一次失败后等 0.05 秒,第二次失败后等 0.10 秒,第三次仍失败就反馈。练习站的 Retry-After 使用秒数形式,函数优先读取该数值;这次服务发出 0,表示下一次尝试可继续经过开始门安排。Retry-After

failure 保留本次异常,最终用 raise ... from failure 把路径、尝试次数和底层原因连起来。response 每轮重新置空,连接失败时也能计算下一次等待。HTML 解析不在这个函数里,所以解析错误不会被误算成网络重试。

2. 让服务连续失败两次再成功

# 项目文件:demos/retry_get.py
# 服务前两次返回 503,第三次成功;重试范围覆盖读取,不覆盖 HTML 字段解析。
import asyncio

import httpx
from crawlsite import running_site
from catalogcrawl.pacing import StartGate
from catalogcrawl.transport import get_response


async def main(base_url):
    async with httpx.AsyncClient(timeout=1, trust_env=False) as client:
        response = await get_response(
            client, base_url + "/books/b103", StartGate(0), attempts=3
        )
        print(response.status_code)


if __name__ == "__main__":
    with running_site() as site:
        site.state.failures["/books/b103"] = 2
        asyncio.run(main(site.base_url))
        print("尝试次数", site.state.counts["/books/b103"])

前两次请求得到 503,日志各记录一次重试;第三次得到 200,最后显示 尝试次数 3。这里重试的是读取。上一章新增阅读日志的 POST 会产生新记录,不能直接套进这段循环,当成同一操作的自动重试。

前两次503分别进入退避与开始门,第三次200才交给解析,结构错误走另一条反馈路径

笔记正文与全部题解 · Ctrl / ⌘ K