实操教程2026年9月29日阅读约 8 分钟

429 Too Many Requests 怎么解决(爬虫限流)

429 Too Many Requests 怎么解决?这是网站在限流。读取 Retry-After 响应头,判断撞上的是哪种限制,再用 Python 限速器按主机控制请求节奏。

429 Too Many Requests 怎么解决?这个错误的意思是:网站统计了你的请求数,认定你暂时超出了它的上限。解决办法就是放慢:读取 Retry-After 响应头,按它说的时间暂停发往这个主机的所有请求,然后以更低、更平稳的速度继续。只有当限制是按 IP 计算时,轮换 IP 才有用。

下面依次讲:这个状态码从哪里来,Retry-After 的两种格式怎么读,常见的限流方式有哪几种,以及一个遵守该响应头、知道何时放弃的 Python 按主机限速器。所有代码都用 HOST、PORT、USERNAME 和 PASSWORD 作占位符,你自己的值在控制台里每项服务的页面上。

429 Too Many Requests 是什么意思?

这是网站给出的回答。代理自己拒绝你时,看起来是另一回事:登录信息不对是 407,代理连不上网站是 502。429 说明你的请求已经到达网站,网站数了数最近从你这里收到多少请求,然后拒绝了。

“你”由网站来定义:可能是你的 IP 地址、账号、API 密钥、某个 Cookie,或者几样的组合。它按什么计数,决定了哪种解决办法有效。术语表里的限流词条有一句话的版本。

想确认 429 是不是来自目标网站,可以通过同一个代理向 https://api.ipify.org 发同样几个请求。如果这些请求都正常,而目标网站还在返回 429,那就是目标网站在限流。

先读 Retry-After 响应头

很多网站会直接告诉你要等多久。看一个 429 响应的响应头:

curl -s -o /dev/null -D - -x "http://USERNAME:PASSWORD@HOST:PORT" "https://TARGET_SITE/page/1"

被限流时的回复大致是这样:

HTTP/2 429
content-type: text/html; charset=utf-8
retry-after: 30

Retry-After 有两种格式,代码里两种都要处理:

  • 秒数,例如 Retry-After: 30。
  • HTTP 日期,例如 Retry-After: Wed, 21 Oct 2015 07:28:00 GMT,意思是“这个时刻之前不要再来”。

Python 标准库就能解析日期格式,不需要装额外的包:

from datetime import datetime, timezone
from email.utils import parsedate_to_datetime


def retry_after_seconds(value):
    if not value:
        return None
    value = value.strip()
    if value.isdigit():
        return int(value)
    try:
        when = parsedate_to_datetime(value)
    except (TypeError, ValueError):
        return None
    if when.tzinfo is None:
        when = when.replace(tzinfo=timezone.utc)
    return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())


print(retry_after_seconds("120"))
print(retry_after_seconds("Wed, 21 Oct 2015 07:28:00 GMT"))
print(retry_after_seconds("soon"))

输出依次是 120;0.0,因为那个日期已经过去;以及 None,表示读不懂这个值。None 就是“没有指示”,交给你自己的退避逻辑处理。

有些 API 还会返回 X-RateLimit-Remaining 和 X-RateLimit-Reset,能在撞线之前提醒你还剩多少额度。

你撞上的是哪种限流?

按什么计数 常见迹象 换 IP 有用吗?
IP 地址 一阵突发请求后开始 429,停一会儿就好;换个新 IP 立刻正常 有用
账号或 API 密钥 只要还登录着或还带着密钥,换到哪个 IP 都是 429 没用
会话或 Cookie 换个新会话就好;同一个 Cookie 在任何 IP 上都失败 新会话也许能解,但靠重置会话躲限制属于规避行为,应该放慢
某个路径或整个网站 搜索这类重页面返回 429,轻页面照常能打开 没用

最省事的测试:遇到 429 后,换一个 IP、不带 Cookie 发一次请求。如果成功,说明限制至少部分是按 IP 计算的。按账号或 API 密钥的限制,是你注册时就接受的规则;要么守在限额以内,要么向网站申请更高的额度。

为什么轮换代理只能解决一部分 429 错误

按 IP 限流是唯一一种轮换能改变算法的情况。住宅代理设为 Randomize IP 时,每个新连接都从不同的地址出去,任何一个地址都攒不起计数。如果用的是一组静态的数据中心或 ISP IP,就要自己把负载分到它们身上。用 Python 轮换代理里两种写法都有,也讲了保持连接会悄悄让轮换失效的问题。

轮换并不会让你的总速率变得礼貌。每分钟一千个请求、来自一千个地址,网站照样看得出规律。轮换是用来分摊一个合理速率的。

429 Too Many Requests 怎么解决:按主机限速

行之有效的做法是:每个主机一个共享的限速器,每个工作线程发请求前都先向它申请;任何一个线程收到 429,整个主机一起暂停。只靠单个请求各自重试是不够的,十个线程各按各的退避时间重试,会不断在网站冷却期间继续敲门。

import random
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.parse import urlsplit

import requests

PROXY = "http://USERNAME:PASSWORD@HOST:PORT"
PROXIES = {"http": PROXY, "https": PROXY}
START_RATE = 1.0
MIN_RATE = 0.1
MAX_WAIT = 600
MAX_ATTEMPTS = 5
stop = threading.Event()


class GiveUp(Exception):
    pass


def retry_after_seconds(value):
    if not value:
        return None
    value = value.strip()
    if value.isdigit():
        return int(value)
    try:
        when = parsedate_to_datetime(value)
    except (TypeError, ValueError):
        return None
    if when.tzinfo is None:
        when = when.replace(tzinfo=timezone.utc)
    return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())


class HostLimiter:
    def __init__(self, rate):
        self.start_rate = rate
        self.lock = threading.Lock()
        self.rate = {}
        self.next_slot = {}
        self.pauses = {}

    def wait(self, host):
        while True:
            with self.lock:
                now = time.monotonic()
                rate = self.rate.setdefault(host, self.start_rate)
                slot = max(now, self.next_slot.get(host, now))
                self.next_slot[host] = slot + 1 / rate
                pauses = self.pauses.get(host, 0)
            time.sleep(slot - now)
            with self.lock:
                if self.pauses.get(host, 0) == pauses:
                    return

    def back_off(self, host, seconds):
        with self.lock:
            self.rate[host] = max(MIN_RATE, self.rate[host] / 2)
            self.next_slot[host] = time.monotonic() + seconds
            self.pauses[host] = self.pauses.get(host, 0) + 1

    def recover(self, host):
        with self.lock:
            self.rate[host] = min(self.start_rate, self.rate[host] * 1.05)


def fetch(url, limiter):
    host = urlsplit(url).hostname
    for attempt in range(MAX_ATTEMPTS):
        limiter.wait(host)
        if stop.is_set():
            return None
        response = requests.get(url, proxies=PROXIES, timeout=(5, 30))
        if response.status_code != 429:
            limiter.recover(host)
            return response
        wait = retry_after_seconds(response.headers.get("Retry-After"))
        if wait is None:
            wait = min(300, 10 * 2 ** attempt) * random.uniform(1, 1.5)
        if wait > MAX_WAIT:
            raise GiveUp(f"{host} asked for a {wait:.0f}s wait")
        print(f"429 from {host}: pausing the host for {wait:.1f}s")
        limiter.back_off(host, wait)
    raise GiveUp(f"{host} still answers 429 after {MAX_ATTEMPTS} attempts")


def crawl(urls, workers=4):
    limiter = HostLimiter(START_RATE)

    def job(url):
        if stop.is_set():
            return url, None
        try:
            return url, fetch(url, limiter)
        except GiveUp as reason:
            if not stop.is_set():
                stop.set()
                print(f"Stopping the run: {reason}")
            return url, None

    with ThreadPoolExecutor(workers) as pool:
        for url, response in pool.map(job, urls):
            if response is not None:
                print(response.status_code, url)


if __name__ == "__main__":
    crawl([f"https://TARGET_SITE/page/{n}" for n in range(1, 21)])

这个限速器怎么工作

  • 均匀的时间槽。 wait() 给每个请求分配该主机的下一个空闲时间槽,与上一个相隔 1 / rate 秒。它相当于一个只装一个令牌的令牌桶,所以不会有突发。START_RATE = 1.0 时,不管开多少个线程,这个主机每秒只会收到一个请求。
  • 线程数不等于速率。 线程数决定同时能有多少个慢响应在等待;速率由限速器决定。
  • 一个 429 让整个主机暂停。 back_off() 把下一个时间槽推到 Retry-After 之后,并把速率减半。已经在等时间槽的线程会发现暂停,重新排到后面。
  • 慢慢恢复。 每成功一次,速率提高 5%,但不会超过起始值。
  • 没有响应头时的兜底。 没有 Retry-After 时,等待时间从大约 10 秒逐步增加到几分钟,并加入一些随机性,避免几个任务同时回来。

我们用一台每秒只允许五个请求的本地测试服务器,通过一个带密码的测试代理跑过这段代码。起始速率设为每秒四个时,30 个页面一个 429 都没有。设为每秒十五个时,遇到了三个 429,两种响应头格式都有;它暂停、把速率减半,最后 40 个页面全部完成。

爬虫什么时候应该停下?

限速器在两种情况下放弃,都是有意为之:

  • 网站要求等很久。 Retry-After 写着一小时,就是网站让你晚点再来。MAX_WAIT 把这种情况变成干净的停止,定时任务可以在下一次运行时接着来。
  • 429 一直不停。 同一个主机连续五次 429,而且已经退避过,说明你对上限的估计是错的。停下来不花你任何成本;继续跑,就会多发几百个请求,而网站看到你无视它的 429,可能会升级为 403 和拦截页。

停下之后,下次运行前先调低 START_RATE。对一个不熟悉的网站,每个主机每秒一个请求是不错的起点。怎么针对具体目标测出安全的并发,见每个代理开多少线程。

429 和 403 有什么区别?

429 的意思是“太多了,等一等”,是暂时的,常常还告诉你要等多久。403 的意思是“不欢迎你”,不会自己结束:你的 IP、指纹或行为已经被判定,等一分钟也没有用。403 的解决办法不一样,爬虫为什么突然被封按顺序讲了。有些网站在别人会返回 429 的地方返回 403 或验证码页,所以如果 403 停一会儿就消失了,就把它当限流处理。

在按 GB 计费的代理上,429 要花多少钱?

在我们的住宅计量里,429 是网站发回来的响应,它的字节和其他响应一样算流量。对 HTTPS 网站来说,状态码走在加密隧道里面,我们的代理读不到,用量表数的是穿过隧道的字节。一个 429 通常很短,只有正常页面的零头,所以单个花不了多少;但一个无视 Retry-After、收下几百个 429 的循环,加起来就不少了。只有连接失败(超时、连接重置,以及我们自己网关返回的错误,比如 407 或 502)计零;规则见计量说明。

更大的浪费在别处:每一个被无视的 429 都会降低你在网站那里的信誉,而随后常常出现的拦截页要重得多,也一样计量。其他失败类型见重试时不浪费流量。

快速问答

429 错误是代理的问题吗? 很少是。这是网站的回复。通过代理向 IP 回显服务发同样的请求来检查:如果那些都正常,就是目标网站在限流。

遇到 429 应该等多久? Retry-After 说多久就等多久。没有这个响应头时,从 10 秒左右开始,每重复一次翻倍,最多等几分钟。

多买代理能解决 429 Too Many Requests 吗? 只有按 IP 限流时才行。按账号、API 密钥或路径的限制,换到哪个 IP 都跟着你。

为什么用了轮换代理还是 429? 你的请求可能共用同一个会话或 Cookie,网站可能不论 IP 对整个路径限流,也可能是一个保持着的连接让所有请求都从同一个出口出去。

下一步

先用限速器以每秒一个请求跑一小批,只有在 429 数量保持为零时才往上调。想通过我们试一试,小额充值就够了,价格见价格页面。如果某个目标在很温和的速度下仍然限流,带上一个 429 响应的响应头,到 Discord 描述一下。

还有后续问题?

去 Discord 问。答案还能帮到下一个读到这个帖子的人。

加入 Discorddiscord.gg/proxypanda
$5 起步去 Discord 问