병렬로 Request 실행

학습할 내용

동시에 열려 있는 호출 수를 제한하고 거부 응답 발생 시 무작정 재시도하지 않고 적절히 대응하여, 429 오류 없이 대량의 FourA request를 동시 실행하는 방법을 알아봅니다.

사전 요구 사항

  • FourA API 키 (여기서 발급)
  • requests가 설치된 Python 3.9+ 또는 Node.js 18+

적용되는 제한 기준

플랜에는 endpoint별로 두 가지 한도가 적용됩니다. 동시에 실행할 수 있는 request 수와 분당 시작할 수 있는 request 수입니다 (Browser는 분당 한도 대신 일일 허용량이 적용됩니다). Single, Proxy, Browser에는 각각 별도의 수치가 적용되며, Usage & Limits의 Limits & Features 탭에 자세히 나와 있습니다.

동시 실행 한도를 초과하는 request는 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
}

분당 상한을 초과하는 요청은 X-FourA-Limit: plan_limit_rate(으)로 반환되며, retry_after_seconds 헤더에 추가 요청이 허용될 때까지 대기해야 하는 시간(1~120초)이 표시됩니다.

두 거부 응답 모두 즉시 반환됩니다. FourA는 호출을 큐에 넣어 나중에 처리하지 않으므로 크레딧이 소모되지 않고 요금도 청구되지 않습니다. 거부된 호출은 롤링 1분 집계에 포함되지 않으므로 상한을 약간 초과해 요청하는 계정도 허용된 상한 전체를 그대로 사용할 수 있습니다. 재시도 폭풍(retry storm)에 대해서는 별도의 대응이 적용됩니다. 거부된 호출이 분당 상한의 2배를 초과하여 계속 유입되면 해당 endpoint의 모든 호출이 cooldown: true 및 retry_after_seconds: 30(으)로 거부되므로 해당 30초 동안 요청을 중단해야 합니다. 전체 필드 레퍼런스: Rate Limits.

자주 간과하기 쉬운 두 가지 사항이 있습니다.

  • POST /api/auto/ 호출 자체는 슬롯을 점유하지 않지만, 해당 호출이 내부적으로 생성하는 모든 Single, Proxy, Browser 서브 호출은 슬롯을 점유합니다. 하나의 auto 호출이 단계적 실행 중에 둘 이상의 슬롯을 점유할 수 있습니다. (대시보드에서 auto 호출은 단일 request로 표시되며 서브 호출은 attempts로 표시됩니다.)
  • Browser 요청은 페이지 렌더링이 완료될 때까지 슬롯을 점유하므로 Single 요청보다 점유 시간이 훨씬 깁니다. 따라서 browser 호출 배치는 single 호출 배치보다 더 적은 요청 수로도 상한에 도달합니다.

1단계: 자체 동시 실행 수 제한하기

호출하려는 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이 전체 메커니즘입니다. 풀은 해당 수 이상의 호출을 열어 두지 않으므로, 배치가 100만 개의 URL이라도 제한을 초과하지 않고 유지됩니다.

여유 공간을 남겨 두십시오. 시스템 내 두 프로세스가 다른 API key를 사용하더라도 동일한 계정을 쓴다면 단일 한도를 공유하므로, 각각 절반씩 할당해야 합니다. 대상 서버의 응답이 빨라 풀이 분당 허용량보다 빠르게 request를 완료한다면, 분당 한도를 넘지 않도록 워커의 작업 속도를 조절하십시오.

2단계: 즉시 재전송 대신 백오프 수행

거부 응답이 오더라도 배치를 한꺼번에 재전송하는 것은 잘못된 해결책입니다. 배치의 모든 호출이 다시 거절당하며, 이미 실행 중인 request 위에 재시도가 누적됩니다.

먼저 대기하십시오. 대기 시간은 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"}

여러 워커를 실행할 때는 지터(jitter)를 추가하십시오. 지터가 없으면 같은 초에 거부된 모든 워커가 동시에 다시 시작되어 다 함께 다시 거부됩니다.

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단계: 실제 사용량 모니터링하기

대시보드에서 사용량 및 제한을 엽니다. 한도 옆의 실시간 카운터에는 동시 실행 수, 분당 rate, 일일 브라우저 request 수가 실시간으로 표시되므로, 추측 대신 실제 실행을 기준으로 MAX_IN_FLIGHT 크기를 조정할 수 있습니다.

활동 로그에는 거부 건도 기록됩니다. 실행 결과 행 수는 맞지만 여러 건의 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/ 내부의 ladder는 실제 하위 호출을 생성하며, 한도에는 이 하위 호출들이 반영됩니다.
  • 예산을 나누지 않고 프로세스 간에 키 공유. 한도는 프로세스 단위가 아니라 계정 단위로 적용됩니다.
  • 모든 endpoint에 대해 단일 풀 크기 적용. Single, Proxy, Browser는 각각 고유한 동시성 수치를 가집니다. Single에 맞춰 설정된 풀은 더 작은 Browser 한도를 초과하게 됩니다.
최근 업데이트: 2026년 10월 15일