并行运行 Request
本篇内容
如何通过限制并发调用数并在遇到拒绝时妥善处理而非盲目重试,从而在高并发运行大批量 FourA request 时避免触发 429 错误。
前置条件
- 一个 FourA API key(在此获取)
- 安装了
requests的 Python 3.9+,或 Node.js 18+
您需要应对的限制
您的套餐针对每个 endpoint 设有两项上限:可同时运行的 request 数量,以及每分钟可发起的 request 数量(Browser 使用每日配额而非每分钟配额)。Single、Proxy 和 Browser 各有独立的配额数值,Usage & Limits 的 Limits & Features 标签页中列出了具体信息。
超出并发上限的 request 将返回 HTTP 429,并在响应体中包含 X-FourA-Limit: plan_limit_concurrency 及具体上限值:
{
"error": "Concurrency limit reached: your plan allows 50 simultaneous single request(s). Retry when an in-flight request finishes.",
"reason": "plan_limit_concurrency",
"documentation": "https://foura.ai/prices",
"limit": 50,
"in_flight": 51,
"retry_after_seconds": 1
}
超过每分钟上限的请求会返回 X-FourA-Limit: plan_limit_rate,且 retry_after_seconds 会持续到当前分钟结束。
这两种拒绝都会立即返回。FourA 不会对调用进行排队并在稍后返回,因此不会产生消耗,也不会计费。不过,被拒绝的调用仍会计入滑动分钟窗口,因此重试风暴会延长自身的冷却时间。完整字段参考:Rate Limits。
有两点常被忽略:
POST /api/auto/调用本身不单独计数,但它为你发起的每个 Single、Proxy 和 Browser 子调用都会计数。一个 auto 调用在其梯度重试运行期间可能会占用多个槽位。- Browser request 占用槽位的时间等于页面渲染所需的时间,这远长于 Single request。与一批 single 调用相比,一批 browser 调用只需更少的请求量就会达到上限。
Step 1: Cap Your Own Concurrency
选择一个低于所调用 endpoint 上限的数值并保持该限制。使用 worker pool 只需一行代码即可实现:
import os
import concurrent.futures
import requests
API = "https://eu.api.foura.ai/api/single/"
KEY = os.environ["FOURA_API_KEY"]
HEADERS = {"X-API-Key": KEY, "Content-Type": "application/json"}
# Below your plan's Single concurrency, so a slow request never pushes the batch over it.
MAX_IN_FLIGHT = 30
def fetch_one(url):
resp = requests.post(API, headers=HEADERS, json={"method": "GET", "url": url}, timeout=60)
return url, resp.status_code, resp.json()
def fetch_all(urls):
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_IN_FLIGHT) as pool:
for outcome in concurrent.futures.as_completed(pool.submit(fetch_one, u) for u in urls):
results.append(outcome.result())
return results
max_workers 就是全部机制所在。连接池中打开的调用永远不会超过该数量,因此即使批处理包含一百万个 URL,也能保持在限制之内。
预留余量。如果你方有两个进程共享一个 API key,它们将共用一个上限,因此请为每个进程分配一半配额。如果目标响应较快,导致连接池在不到一分钟内就完成请求,请控制 worker 的速率,以确保同时低于每分钟上限。
步骤 2:退避而非立即重发
如果确实收到了拒绝响应,错误的做法是立即重新发送该批次。其中的每个调用都会再次被拒绝,重试请求还会叠加到已经在运行的请求之上。
先等待。等待时间可在 Retry-After header 以及 body 的 retry_after_seconds 中找到:
import time
# Plan limits that no short wait will clear.
STOP_ON = {"plan_limit_browser_daily", "plan_limit_credits", "plan_limit_bandwidth", "plan_limit_feature", "plan_limit_premium"}
def fetch_one(url, attempts=4):
for attempt in range(attempts):
resp = requests.post(API, headers=HEADERS, json={"method": "GET", "url": url}, timeout=60)
limit = resp.headers.get("X-FourA-Limit")
if limit in STOP_ON:
raise RuntimeError(f"stopped by {limit}")
if resp.status_code not in (429, 503):
return url, resp.status_code, resp.json()
header = resp.headers.get("Retry-After")
if header and header.isdigit():
wait = int(header)
else:
body = resp.json()
wait = body.get("retry_after_seconds") or body.get("retryAfter") or 2 ** attempt
time.sleep(wait)
return url, 429, {"error": "still refused after retries"}
运行多个 worker 时请添加抖动(jitter)。如果不添加,在同一秒内被拒绝的每个 worker 都会在同一秒被唤醒并再次一同被拒绝。
import random
time.sleep(wait + random.uniform(0, 0.5))
步骤 3:在 Node 中实现相同功能
const API = 'https://eu.api.foura.ai/api/single/';
const HEADERS = {
'X-API-Key': process.env.FOURA_API_KEY,
'Content-Type': 'application/json',
};
const MAX_IN_FLIGHT = 30;
async function fetchOne(url) {
const resp = await fetch(API, {
method: 'POST',
headers: HEADERS,
body: JSON.stringify({ method: 'GET', url }),
});
return { url, status: resp.status, body: await resp.json() };
}
async function fetchAll(urls) {
const queue = [...urls];
const results = [];
async function worker() {
while (queue.length) {
results.push(await fetchOne(queue.pop()));
}
}
await Promise.all(
Array.from({ length: Math.min(MAX_IN_FLIGHT, urls.length) }, worker)
);
return results;
}
固定数量的 worker 从单个队列拉取任务,无论批处理量多大,都能将并发调用数精确维持在设定值。
步骤 4:监控实际使用量
在 控制台中打开 Usage & Limits。上限旁边的实时计数器会动态显示并发数、每分钟速率以及每日 browser request 数量,方便你根据实际运行情况而非猜测来调整 MAX_IN_FLIGHT。
Activity Log 也会记录被拒绝的请求。如果某次运行最终获取了正确数量的行,但夹杂着零星的 rate_limit 结果,说明它触发了自身限流。
常见错误
- 一次性发起整个列表的请求。 对 5,000 个 URL 执行
asyncio.gather,或对无界数组执行Promise.all,会同时发起 5,000 个调用。应当限制并发池大小,而不是限制列表。 - 并行重试。 在请求被拒绝的瞬间立即重新发送,会再次引发导致拒绝的突发流量。请等待
Retry-After指定的时间,并加入随机抖动 (jitter)。 - 将每日或账单周期的配额限制当作短暂等待。
plan_limit_browser_daily会在 UTC 时间午夜重置,plan_limit_credits和plan_limit_bandwidth则在计费周期结束时重置。此时应停止运行,并在存在响应体时读取其中的resets_at。 - 将 auto 调用视为仅占用一个槽位。
/api/auto/内部的阶梯策略会发起实际的子调用,这些子调用都会计入配额上限。 - 跨进程共享密钥却未划分配额。 配额上限是基于账户而非基于进程计算的。
- 对所有 endpoint 使用相同大小的并发池。 Single、Proxy 和 Browser 各自具有独立的并发限制。针对 Single 配置的并发池可能会超出较小的 Browser 并发上限。
相关内容
- Rate Limits:各类拒绝形式及各字段含义
- Response Headers:
X-FourA-Limit与Retry-After - Request Outcomes:为什么
rate_limit结果从不计费 - Usage & Limits:实时计数器与套餐配额数据查看位置
- Smart Fetch (Auto):单个 auto 调用如何转化为多个子调用