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 描述一下。