request を並列実行する

学ぶ内容

オープンにしておく呼び出し数を制限し、リクエストを単純に再試行するのではなく拒絶レスポンスを適切に処理することで、429エラーを発生させずにFourAリクエストの大規模バッチを並行実行する方法を解説します。

前提条件

対象となるレート制限

ご契約プランには、各endpointごとに2つの上限が設定されています。同時に実行可能なリクエスト数と、1分あたりに開始可能なリクエスト数です(Browserには1分あたりではなく1日あたりの許容量が設定されています)。Single、Proxy、Browserにはそれぞれ独自の上限値があり、Usage & LimitsのLimits & Featuresタブに記載されています。

同時実行数の上限を超えたリクエストは、X-FourA-Limit: plan_limit_concurrencyおよびボディに対象の上限値を含むHTTP 429として返されます。

{
  "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
}

1分あたりの上限を超えたリクエストにはX-FourA-Limit: plan_limit_rateが返され、次のリクエストが受け付け可能になるまでの秒数(1~120秒)がretry_after_secondsに示されます。

どちらの拒否も即時に行われます。FourAは呼び出しをキューに入れて後から返すことはないため、リソースの消費や課金は発生しません。拒否された呼び出しはスライディング分としてカウントされないため、上限をわずかに上回るリクエストを送信しているアカウントでも、上限値一杯までリクエストを処理できます。リトライストームには専用の処理があります。拒否された呼び出しが1分あたりの上限の2倍以上のレートで継続して届く場合、そのendpointに対するすべての呼び出しがcooldown: trueおよびretry_after_seconds: 30で拒否されるため、その30秒間はリクエストを停止してください。完全なフィールドリファレンス: Rate Limits。

見落とされやすい重要な点が2つあります。

  • POST /api/auto/の呼び出し自体はスロットを保持しませんが、内部で実行されるSingle、Proxy、Browserの各サブコールがスロットを保持します。1つのauto呼び出しがフォールバック処理を実行している間、複数のスロットを保持する場合があります。(ダッシュボード上では、auto呼び出しがリクエストとして扱われ、そのサブコールが試行回数となります。)
  • Browserリクエストはページのレンダリングが完了するまでスロットを占有するため、Singleリクエストよりもはるかに長い時間を要します。Browser呼び出しのバッチは、Single呼び出しのバッチよりも少ないリクエスト数で上限に達します。

ステップ 1: 自側の同時実行数を制限する

呼び出すendpointの上限値を下回る数値を設定し、それを維持します。ワーカープールを使用すると、1行でこれを実装できます。

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であっても制限内に収まります。

余裕を持たせてください。システム側の2つのプロセスが同じアカウントを使用している場合、異なるAPIキーを使用していても同じ上限を共有するため、それぞれに半分ずつ割り当てます。ターゲットの応答が高速で、プールが1分間に許容される以上のペースでリクエストを完了できる場合は、1分あたりの上限を下回るようワーカーのペースも調整してください。

ステップ 2: すぐに再送信せずバックオフする

拒否レスポンスが届いた場合、すぐにバッチを再送信するのは誤りです。バッチ内のすべての呼び出しが再び拒否され、すでに実行中だったリクエストの上にリトライが重なることになります。

まずは待機してください。待機時間は Retry-After ヘッダーおよびボディ内の 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"}

多数のワーカーを実行する場合はジッターを追加してください。ジッターがないと、同じ秒に拒否されたすべてのワーカーが同じ秒に再開し、一斉に再度拒否されます。

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;
}

1つのキューから取得する固定数のワーカーを使用すると、バッチサイズに関係なく、開かれる呼び出しの数を正確にその数に維持できます。

ステップ4: 実際の使用状況の監視

ダッシュボードの Usage & Limits を開きます。上限の横にあるライブカウンターには、同時実行数、1分あたりのレート、1日あたりのブラウザリクエスト数がリアルタイムで表示されるため、推測ではなく実際の実行結果に基づいて MAX_IN_FLIGHT のサイズを設定できます。

Activity Log には拒否も記録されます。正しい行数で終了したものの、rate_limit の結果が散発的に発生していた実行は、自身でスロットリングを引き起こしていました。

よくある間違い

  • リスト全体を一度に実行する。 5,000件のURLに対する asyncio.gather や、上限のない配列に対する Promise.all は、5,000件の呼び出しを同時に開きます。リストではなく、プールを制限してください。
  • 並列でリトライする。 拒否された呼び出しを拒否された瞬間に再送信すると、拒否の原因となったバーストが再発します。Retry-After を待機し、ジッターを追加してください。
  • 1日または期間の許容量を待機時間として扱う。 plan_limit_browser_daily はUTCの深夜にリセットされ、plan_limit_credits および plan_limit_bandwidth は請求期間の終了時にリセットされます。実行を停止し、ボディに resets_at が含まれている場合はそれを確認してください。
  • 自動呼び出しをそれぞれ1スロットとしてカウントする。 /api/auto/ の内部ラダーは実際のサブ呼び出しを行い、上限でカウントされるのはそれらです。
  • バジェットを分割せずにプロセス間でキーを共有する。 上限はプロセスごとではなく、アカウントごとです。
  • すべてのエンドポイントに1つのプールサイズを適用する。 Single、Proxy、Browser にはそれぞれ独自の同時実行数の値があります。Single 向けにサイズ設定されたプールは、より小さい Browser の上限を超過します。
  • Rate Limits: すべての拒否パターンと各フィールドの意味
  • Response Headers: X-FourA-Limit および Retry-After
  • Request Outcomes: rate_limit の結果に対して請求が発生しない理由
  • Usage & Limits: ライブカウンターおよびプランの数値の確認場所
  • Smart Fetch (Auto): 1つの自動呼び出しが複数のサブ呼び出しに変換される仕組み
最終更新日: 2026年10月15日