并行运行 Request

本篇内容

如何通过限制并发调用数并在遇到拒绝时妥善处理而非盲目重试,从而在高并发运行大批量 FourA request 时避免触发 429 错误。

前置条件

  • 一个 FourA API key(在此获取
  • 安装了 requests 的 Python 3.9+,或 Node.js 18+

您需要应对的限制

您的套餐针对每个 endpoint 设有两项上限:可同时运行的 request 数量,以及每分钟可发起的 request 数量(Browser 使用每日配额而非每分钟配额)。Single、Proxy 和 Browser 各有独立的配额数值,Usage & LimitsLimits & 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_creditsplan_limit_bandwidth 则在计费周期结束时重置。此时应停止运行,并在存在响应体时读取其中的 resets_at
  • 将 auto 调用视为仅占用一个槽位。 /api/auto/ 内部的阶梯策略会发起实际的子调用,这些子调用都会计入配额上限。
  • 跨进程共享密钥却未划分配额。 配额上限是基于账户而非基于进程计算的。
  • 对所有 endpoint 使用相同大小的并发池。 Single、Proxy 和 Browser 各自具有独立的并发限制。针对 Single 配置的并发池可能会超出较小的 Browser 并发上限。

相关内容

更新于: 2026年9月10日