병렬로 Request 실행하기

학습할 내용

열려 있는 호출 수를 제한하고 요청 거부에 대해 반복 재시도 대신 적절히 대응하여, 429 오류를 받지 않고 대량의 FourA request를 동시 실행하는 방법.

사전 요구 사항

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

적용되는 한도

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

동시 실행 한도를 초과하는 request는 body에 한도 정보 및 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와 함께 반환됩니다.

두 거부 모두 즉각적입니다. FourA는 호출을 대기열에 넣고 나중에 반환하지 않으므로, 아무것도 소모되지 않고 요금도 청구되지 않습니다. 하지만 거부된 호출도 슬라이딩 분 계산에 포함되므로, 재시도 폭풍은 자체 쿨다운을 연장시킵니다. 전체 필드 참조: Rate Limits.

자주 간과되는 두 가지 사항은 다음과 같습니다.

  • POST /api/auto/ 호출 자체는 계산되지 않지만, 대신 실행되는 모든 Single, Proxy 및 Browser 하위 호출은 계산됩니다. 단일 auto 호출은 래더가 실행되는 동안 둘 이상의 슬롯을 차지할 수 있습니다.
  • Browser request는 Single request보다 훨씬 긴 페이지 렌더링 시간 동안 슬롯을 차지합니다. 따라서 browser 호출 배치는 single 호출 배치보다 적은 수의 request로 한도에 도달합니다.

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이 메커니즘의 전부입니다. 풀은 해당 수치를 초과하여 호출을 열어두지 않으므로, 배치가 100만 개의 URL로 구성되어도 한도 미만을 유지합니다.

여유 공간을 확보하십시오. 사용자 측의 두 프로세스가 하나의 API 키를 공유하면 하나의 상한을 공유하게 되므로, 각각 절반씩 할당하십시오. 빠른 대상 서버로 인해 풀이 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;
}

단일 큐에서 작업을 가져오는 고정된 수의 워커를 사용하면 배치 크기와 관계없이 정확히 해당 수만큼의 호출만 열린 상태로 유지됩니다.

4단계: 실제 사용량 모니터링하기

대시보드Usage & Limits를 엽니다. 한도 옆의 실시간 카운터에 동시 실행 수, 분당 요청률, 일일 브라우저 요청 수가 실시간으로 표시되므로, 추측 대신 실제 실행을 바탕으로 MAX_IN_FLIGHT 크기를 조정할 수 있습니다.

Activity Log에는 거부된 요청도 기록됩니다. 원하는 행 수로 완료되었더라도 rate_limit 결과가 흩어져 있다면 자체적으로 throttling이 발생한 것입니다.

자주 발생하는 실수

  • 전체 목록을 한 번에 요청하기. 5,000개 URL에 대한 asyncio.gather나 제한 없는 배열에 대한 Promise.all는 5,000개의 호출을 엽니다. 목록이 아닌 풀을 제한하세요.
  • 병렬로 재시도하기. 거부된 모든 호출을 거부 즉시 다시 보내면 거부를 유발한 버스트가 그대로 재현됩니다. Retry-After 동안 대기하고 지터를 추가하세요.
  • 일일 또는 기간 할당량을 대기 시간으로 취급하기. plan_limit_browser_daily는 자정(UTC)에 초기화되고, plan_limit_creditsplan_limit_bandwidth는 결제 주기 종료 시 초기화됩니다. 실행을 중지하고 본문에 resets_at가 있다면 이를 확인하세요.
  • auto 호출을 각각 하나의 슬롯으로 계산하기. /api/auto/ 내부의 사다리는 실제 하위 호출을 생성하며, 한도에는 이 하위 호출이 반영됩니다.
  • 예산을 나누지 않고 프로세스 간에 키 공유하기. 한도는 프로세스 단위가 아니라 계정 단위로 적용됩니다.
  • 모든 엔드포인트에 단일 풀 크기 적용하기. Single, Proxy, Browser는 각각 고유한 동시성 수치를 가집니다. Single에 맞춰 조정된 풀은 더 작은 Browser 한도를 초과하게 됩니다.

관련 항목

최근 업데이트: 2026년 9월 10일