リクエストの並行実行

学べること

同時実行数を制限し、リクエスト拒否に対して再試行を繰り返さず適切に対処することで、429エラーを回避しながら大量のFourA requestを並行処理する方法を学びます。

前提条件

  • FourA APIキー(こちらから取得
  • Python 3.9以上(requestsを使用)、またはNode.js 18以上

適用される上限値

ご利用のプランには、endpointごとに2つの上限が設定されています。同時に実行できるrequest数と、1分あたりに開始できるリクエスト数です(Browserには1分あたりではなく1日あたりの上限が適用されます)。Single、Proxy、Browserにはそれぞれ固有の数値があり、Usage & LimitsLimits & Featuresタブに記載されています。

同時実行数の上限を超えたrequestは、HTTP 429(X-FourA-Limit: plan_limit_concurrency)とともにbodyに上限値が含まれて返されます。

{
  "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 が返され、その分の終了時まで retry_after_seconds が設定されます。

どちらの拒否も即座に行われます。FourA はコールをキューに入れて後から処理することはないため、クレジットの消費や課金は発生しません。ただし、拒否されたコールもスライディングウィンドウの1分間のカウント対象となるため、リトライが殺到するとクールダウンが延長されます。フィールドの完全なリファレンス: Rate Limits

見落とされがちな2つの注意点:

  • POST /api/auto/ コール自体はカウントされませんが、内部で実行される各 Single、Proxy、Browser のサブコールはカウントされます。1つの auto コールがラダー実行中に複数のスロットを保持する場合があります。
  • Browser リクエストは、ページのレンダリングにかかる時間全体にわたってスロットを占有します。これは Single リクエストよりもはるかに長くなります。そのため、Browser コールのバッチは、Single コールのバッチよりも少ないリクエスト数で上限に達します。

Step 1: Cap Your Own Concurrency

呼び出す 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つのプロセスで1つの API キーを共有する場合、上限も共有されるため、それぞれに半分ずつ割り当てます。ターゲットの応答が速く、1分あたりの許容量よりも早くプール内のリクエストが完了する場合は、1分あたりの上限を超えないようワーカーのペースを調整してください。

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

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

まずは待機してください。待機時間は 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/ 内のラダーは実際のサブ呼び出しを実行し、上限でカウントされるのはそれらのサブ呼び出しです。
  • バジェットを分割せずに複数プロセス間でキーを共有する: 上限はプロセスごとではなく、アカウントごとに設定されています。
  • すべてのエンドポイントに単一のプールサイズを適用する: 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年9月10日