爬虫遇到 HTTP 429 Too Many Requests 怎么修

http 429 too many requests - How to Fix HTTP 429 Too Many Requests While Scraping

HTTP 429 Too Many Requests 的意思是慢一点,不是走开。服务器在告诉你,它仍然想要你的流量,只是速率要低一些,而且通常会直接说清楚要等多久。所以解决办法几乎从来不是换代理或换 user agent,而是读一个响应头、老实地睡一会儿,再限制同时在飞的请求数量。这篇文章讲完这三件事,然后告诉你怎么把真正的限流和披着同一个状态码的机器人拦截区分开,因为这两者需要的反应正好相反。

你需要什么

  • 示例需要 Python 3.10 或更新的版本,以及 requests 库。这套逻辑可以直接搬到任何 HTTP 客户端上。
  • 一个终端,好让你在动手写重试代码之前先看看响应头。
  • 运行 CapSkip,这一项只有最后一节才用得上:要么在 loopback 地址上用本地模式,要么在你的 worker 能访问到的机器上用服务器模式。两者都记录在 连接设置一节,所以开始之前先选一种。

第 1 步:先读响应,再决定重试

多数针对 429 的处理都是闭着眼写的,所以它们不管用。先看真正的响应。状态码是连着响应头一起来的,这些头会告诉你限额是多少、什么时候重置,而不同服务用的头并不一样。

# No install needed. Dump headers, throw the body away.
curl -sS -o /dev/null -D - "https://example.com/api/items?page=2"

# Look for these, in this order of usefulness:
#   Retry-After: 30           seconds, or an HTTP date
#   RateLimit-Reset: 1724500000
#   X-RateLimit-Remaining: 0
#   RateLimit-Limit: 100

真正要紧的是 Retry-After。它就是为这种情形定义的,而且有两种形式:等待的秒数,或者一个绝对的 HTTP 日期。两种都合法,两种在真实站点上都能碰到,而只认数字的代码会在发送日期的那些站点上出错。 MDN 记录了这两种形式,它关于 429 状态码 的那个参考页也值得你花两分钟看看。

解析时要留有余地:有就照它办,没有就退回你自己的节奏。

# pip install requests
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone

def retry_delay(response, fallback):
    """Seconds to wait, from Retry-After if the server sent one."""
    raw = response.headers.get("Retry-After")
    if not raw:
        return fallback
    try:
        return max(0.0, float(raw))          # the delay-seconds form
    except ValueError:
        pass
    try:
        when = parsedate_to_datetime(raw)    # the HTTP-date form
        return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
    except (TypeError, ValueError):
        return fallback

对拿到的值设个上限。要求 3600 秒的服务器是在让你停一个小时,而在请求处理函数里老老实实睡这么久的 worker,在上层看来就是卡死了。取响应头的值和你自己上限之中较小的那个,然后再单独决定要不要把这个任务先放一放。

第 2 步:指数退避,并且加随机抖动

如果没有 Retry-After 可以照着做,那就每次把等待时间翻一倍,并且加进随机性。翻倍是为了让你别再去锤一个已经吃力的服务。随机性是为了让同一秒里一起撞上限额的二十个 worker,不会永远在同一秒里一起重试。

# pip install requests
import random, time, requests

def get_with_backoff(url, attempts=5, base=1.0, ceiling=60.0):
    for attempt in range(attempts):
        response = requests.get(url, timeout=30)
        if response.status_code != 429:
            return response
        # Full jitter: sleep somewhere in [0, base * 2 ** attempt].
        window = min(ceiling, base * (2 ** attempt))
        delay = retry_delay(response, random.uniform(0, window))
        time.sleep(min(delay, ceiling))
    raise RuntimeError(f"still rate limited after {attempts} attempts")

# Waits land near 0-1s, 0-2s, 0-4s, 0-8s, 0-16s unless the
# server named a delay, in which case that wins.

所谓 full jitter,是在零到这个窗口之间取一个随机值,而不是窗口再加上一点小抖动,它能最快让一个集群错开。如果你不想自己写,urllib3 里本来就有,但要让它真正起作用,有两个参数必须显式设置:

# pip install requests
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

retry = Retry(
    total=5,
    # status_forcelist defaults to none, so 429 is NOT retried
    # unless you list it here yourself. This is the usual bug.
    status_forcelist=[429, 500, 502, 503, 504],
    backoff_factor=1,        # 1 * 2 ** previous_retries seconds
    backoff_jitter=1.0,      # urllib3 2.x only
    allowed_methods=["GET", "HEAD"],
)

session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))

关于这个类有两个细节值得知道。Retry-After 它自己就会遵守,因为 429 是它 RETRY_AFTER_STATUS_CODES 集合里的三个状态码之一,另两个是 413 和 503,而 respect_retry_after_header 默认为 true。但 status_forcelist 默认是空的,所以一个新建的 Retry 对象在你把 429 写进去之前根本不会重试它。大家往往以为正好相反,然后纳闷适配器怎么什么都没做。

第 3 步:限制并发,而不是更用力地重试

重试逻辑治的是症状。如果 429 是稳定地来而不是一阵一阵地来,那你就是在要比允许量更多的东西,办法是少发。一个信号量,加上请求之间的最小间隔,能解决的限流问题比任何退避曲线都多。

# Standard library only.
import asyncio

# Six in flight is a sane starting point for an unknown API.
gate = asyncio.Semaphore(6)
MIN_GAP = 0.2          # seconds between starts, per worker

async def fetch(client, url):
    async with gate:
        response = await client.get(url)
        await asyncio.sleep(MIN_GAP)
        return response

# Tune down on the first 429, and stay there for a while.
# Tuning back up too eagerly just rediscovers the limit.

碰到 429 之后的第一反应,往往是把同样的负载摊到更多 IP 上。对某些目标这确实管用,但那是另一个有自身取舍的决定,我们在 验证码代理轮换里单独讲过。把它当成容量决策来做,而不是当成不读一个响应头的借口。

429 不是 403,挑战页也不是这两者

429 的处理最容易在这里出昂贵的错。限流和机器人检测是两套不同的系统,只是有时共用同一个状态码,而它们要你做的事正好相反。来自限流器的 HTTP 429 Too Many Requests 是一条排期指令,同样的状态码来自反机器人边缘节点就是一次拒绝。对着机器人拦截礼貌地退避,白白浪费一小时。对着真正的限流用力重试,会让你的 IP 被封。

你拿到了什么它通常意味着什么真正有用的做法
带 Retry-After 响应头的 429一个真实且有文档的限流就等那么久,然后把速率降下来
没有响应头、正文是 HTML 的 429边缘节点或反机器人层,不是 API 本身当成拦截来处理,而不是当成限额
瞬间就返回的 403指纹、TLS 或者 IP 信誉去改客户端,因为等待改变不了任何事
带 Retry-After 的 503过载或者在维护和 429 走同一条退避路径
正文里装着挑战页的 200你被评分之后被打断了把挑战解掉,然后继续

最后那一行最容易骗到人,因为什么都没有失败。请求返回了 200,正文里装的却是挑战页而不是你的数据,所以只看状态码的重试循环会开开心心地永远锤下去。请检查正文里有没有你预期的那个标记,而不是只看状态行。如果你找到的是挑战,答案不是退避,而是把它解掉。

别让你的求解循环踩同一个坑

等待验证码答案的轮询循环本身就是一个重试循环,而手写的版本会犯上面一模一样的错。CapSkip 有两点让这件事比对着按次计费的服务更省心。它跑在你自己的硬件上,所以既没有会用尽的按次配额,也没有自己的限流可撞。而且 SDK 已经替你做了退避:轮询从四分之一秒起步,一路增长到 pollingInterval,而那是一个上限,不是固定的间隔。

# pip install capskip
from capskip import CapSkip, NetworkException, TimeoutException

# Local mode. In Server mode, host is the solver box's address.
solver = CapSkip(host="127.0.0.1", port=8080, pollingInterval=2)

try:
    result = solver.recaptcha(
        sitekey="YOUR_SITEKEY",
        url="https://example.com/page-with-recaptcha",
    )
    print(result["code"][:24])   # token, submit it with the form
except TimeoutException:
    # Polling ran past recaptchaTimeout, 300 seconds by default.
    print("gave up waiting, try again or lower the timeout")
except NetworkException:
    # The solver is not reachable on that host and port.
    print("check CapSkip is running and the mode you set")

把 pollingInterval 调小,答案会更早回来,而且不花你一分钱,这个选择在计费的 API 上你基本没有。如果你不用 SDK 而是直接轮询原始的 HTTP 端点,每种验证码类型建议的间隔都列在 API 文档里,同时也写了求解还在进行时你会拿到的那个等待响应。

改成把求解器跑在服务器上

限流通常是一个集群的问题,而不是一个脚本的问题,而集群并不共用同一个 loopback 地址。连接设置把两种情况都覆盖了:

模式监听地址适用场景
本地127.0.0.1,仅限该设备你的爬虫和求解器跑在同一台机器上
服务器你的内网地址或公网 IPworker、容器、一台 VPS 或者托管平台通过 API 调用过来

把 SDK 的 host 指向跑求解器的那台机器,你代码里其他什么都不用改,于是十个 worker 可以共用一个实例。如果调用方在你自己的网络之外,建议准备静态公网 IP。细节见 连接设置,而服务器模式仍然是你的硬件,也仍然不计量:它改变的是求解器在哪里运行,而不是它归谁所有。

常见问题

我该永远听 Retry-After 的话吗?

照它办,但要设上限。关于窗口什么时候重新打开,它是你能拿到的最可靠的信号,忽略它就等于猜得比服务器已经告诉你的还差。不过一小时这种值是另一个决定:把任务放一放、过会儿再来,而不是让一个 worker 一直睡着,因为上层的一切都会把那读成卡死。

怎么分辨限流和机器人拦截?

看看状态码是跟什么一起来的。真正的限额是机器可读的:一个 Retry-After 或 RateLimit 响应头、一小段 JSON 正文,以及你等待之后表现一致。机器人拦截给你的是一个 HTML 页面、没有任何时间信息,而且不管你放多久往往都是同一个响应。后者要的是换一个客户端,而不是睡得更久。

求解器会返回 429 吗?

不会。CapSkip 跑在你自己掌控的硬件上,没有按次配额,所以既没有会用尽的计费窗口,也没有上游限额可撞。如果一次求解调用失败,你拿到的会是网络错误,因为守护进程连不上;或者是超时,因为轮询超过了它的上限。两者都说明问题出在本地,所以去查 host、端口和你配置的模式。

我的 worker 跑在托管平台上,求解器该放哪里?

放在那个平台能访问到的、属于你的机器上,并把求解器切到服务器模式。托管 runner 和托管的自动化平台看不到你的 loopback 地址,所以把 API 绑到你的内网地址或公网 IP,再让每个 worker 都指向它。一个实例就能服务整个集群,也不需要隧道。

最短的版本

HTTP 429 Too Many Requests 是一个排期问题,就按排期问题来对待。读 Retry-After,并在你自己选定的上限内照它办。响应头缺失时,退回到带 full jitter 的指数退避。然后把并发降下来,因为稳定出现的 429 是容量问题,任何重试曲线都解决不了。而且在你重试之前先看正文,因为挑战页是带着一个完全健康的状态码来的,它要的是被解掉,而不是被等。这部分需要的是在本地运行的 无限量验证码识别工具 ,而它怎么嵌进 worker 池, 网络爬虫验证码识别工具 里讲得很清楚。