# 并行运行请求

> URL: https://foura.ai/zh/docs/how-to/run-requests-in-parallel
> Updated: 2026-10-15
> Index: https://foura.ai/llms.txt

## 你将学到什么

如何通过限制保持打开的调用数量，并在遇到拒绝时做出相应处理而非盲目重试，来并发运行大批量 FourA request 并避免触发 429 错误。

## 前置条件

- 一个 FourA API key（[在此获取](https://foura.ai/docs/getting-started/authentication)）
- Python 3.9+ 及 `requests`，或 Node.js 18+

## 你需要应对的限制

你的套餐对每个 endpoint 设有两个上限：可同时运行的 request 数量，以及每分钟可发起的 request 数量（Browser 采用按日配额而非按分钟配额）。Single、Proxy 和 Browser 各有独立配额，[Usage & Limits](https://foura.ai/docs/dashboard/quota) 的 **Limits & Features** 标签页列出了这些数值。

超出并发上限的 request 会返回 HTTP 429，并在 body 中包含 `X-FourA-Limit: plan_limit_concurrency` 及对应上限值：

```json
{
  "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` 会指明还需等待多久才能再进入一个请求（1 到 120 秒）。

两种拒绝都是即时的。FourA 不会对调用进行排队并在稍后返回，因此不会产生任何消耗，也不会计费。被拒绝的调用不计入滑动分钟窗口，因此请求量略微超出上限的账户仍可获得完整的上限配额。重试风暴有单独的处理方式：当被拒绝的调用持续以超过每分钟上限两倍的速率到达时，该 endpoint 上的所有调用都会被拒绝，并返回 `cooldown: true` 和 `retry_after_seconds: 30`，因此请暂停这 30 秒。完整字段参考：[Rate Limits](https://foura.ai/docs/api/rate-limits)。

人们经常忽略的两点：

- 一个 `POST /api/auto/` 调用本身不占用槽位，但它为你发起的每个 Single、Proxy 和 Browser 子调用都会占用。一个 auto 调用在其梯级策略运行时可能会占用多个槽位。（在控制面板中，auto 调用是 request，其子调用是 attempts。）
- Browser 请求占用槽位的时间取决于页面渲染所需的时间，这远长于 Single 请求。一批 browser 调用比一批 single 调用更快达到上限。

## 第 1 步：限制自身的并发量

选择一个低于所调用 endpoint 上限的数值并保持它。Worker pool 只需一行代码即可实现：

```python
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` 就是全部机制。连接池中的并发调用数绝不会超过该值，因此即便批处理包含 100 万个 URL，也不会超出限制。

预留余量。如果您这边的两个进程使用同一个账户，即使使用不同的 API key，它们也会共享同一个上限，因此请为每个进程分配一半。如果目标站点响应较快，使连接池在 1 分钟内完成的请求数过高，请调整 worker 速率以确保不超出每分钟上限。

## 步骤 2：退避而非立即重发

如果收到拒绝响应，立即重发该批次是错误的做法。其中的每次调用都会再次被拒，重试请求还会叠加在已运行的请求之上。

先等待。等待时长可通过 `Retry-After` header 以及响应体中的 `retry_after_seconds` 获取：

```python
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 都会在同一秒被唤醒，并再次一同被拒绝。

```python
import random
time.sleep(wait + random.uniform(0, 0.5))
```

## 步骤 3：在 Node 中实现相同逻辑

```javascript
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：监控实际用量

在 [控制台](https://foura.ai/dashboard)中打开**用量与限制**。上限旁的实时计数器会动态显示并发数、每分钟速率以及每日浏览器 request 数，方便你根据实际运行情况而非猜测来调整 `MAX_IN_FLIGHT`。

[活动日志](https://foura.ai/docs/dashboard/activity-log)也会记录拒绝情况。如果一次运行最终处理了正确数量的行，但零星出现了 `rate_limit` 结果，说明它触发了自身限流。

## 常见错误

- **一次性触发整个列表。** 对 5,000 个 URL 执行 `asyncio.gather`，或对无界数组执行 `Promise.all`，会发起 5,000 个调用。应当限制并发池大小，而不是限制列表。
- **并行重试。** 在请求被拒绝的瞬间立即重发，会再次引发导致拒绝的突发流量。请等待 `Retry-After`，并加入随机抖动。
- **将每日或周期配额当作临时等待。** `plan_limit_browser_daily` 会在 UTC 午夜清零，`plan_limit_credits` 和 `plan_limit_bandwidth` 会在计费周期结束时重置。应停止运行，并在响应体存在时读取 `resets_at`。
- **将 auto 调用计为单个槽位。** `/api/auto/` 内部的阶梯机制会产生真实的子调用，上限统计的是这些子调用。
- **跨进程共享密钥却未分配预算。** 配额上限基于账户计算，而非基于进程。
- **为所有 endpoint 配置统一大小的池。** Single、Proxy 和 Browser 各自拥有独立的并发配额。针对 Single 配置的池大小可能会超出较小的 Browser 上限。

## 相关内容

- [Rate Limits](https://foura.ai/docs/api/rate-limits)：所有拒绝形式及各个字段的含义
- [Response Headers](https://foura.ai/docs/api/response-headers)：`X-FourA-Limit` 与 `Retry-After`
- [Request Outcomes](https://foura.ai/docs/api/outcomes)：为什么 `rate_limit` 结果绝不会计费
- [用量与限制](https://foura.ai/docs/dashboard/quota)：实时计数器与套餐额度所在位置
- [Smart Fetch (Auto)](https://foura.ai/docs/guides/smart-fetch)：单个 auto 调用如何转化为多个子调用
