Wykonywanie równoległych requestów

Czego się dowiesz

Jak uruchamiać dużą partię żądań FourA współbieżnie bez otrzymywania błędów 429, ograniczając liczbę otwartych wywołań i reagując na odrzucenie zamiast jego powtarzania.

Wymagania wstępne

Limity, z którymi pracujesz

Twój plan nakłada dwa limity na dany endpoint: ile żądań może być przetwarzanych jednocześnie oraz ile może wystartować w ciągu minuty (Browser ma limit dzienny zamiast minutowego). Single, Proxy i Browser mają własne limity, a zakładka Limits & Features na stronie Usage & Limits zawiera ich listę.

Żądanie, które przekracza limit współbieżności, zwraca kod HTTP 429 z X-FourA-Limit: plan_limit_concurrency oraz wartością limitu w treści odpowiedzi:

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

Żądanie przekraczające limit na minutę zwraca X-FourA-Limit: plan_limit_rate, a nagłówek retry_after_seconds określa czas oczekiwania na możliwość wysłania kolejnego żądania (od 1 do 120 sekund).

W obu przypadkach odrzucenie następuje natychmiastowo. FourA nie kolejkuje wywołań ani nie przetwarza ich z opóźnieniem, więc środki nie są pobierane ani fakturowane. Odrzucone wywołanie nie wlicza się do ruchomego okna minutowego, dzięki czemu konto nieznacznie przekraczające limit nadal wykorzystuje pełną dostępną przepustowość. Zjawisko retry storm jest obsługiwane osobno: gdy odrzucone wywołania nadal napływają z częstotliwością ponad dwukrotnie przewyższającą limit na minutę, każde wywołanie na tym endpoincie jest odrzucane z cooldown: true oraz retry_after_seconds: 30, dlatego należy wstrzymać ruch na 30 sekund. Pełna dokumentacja pól: Rate Limits.

Dwa istotne elementy, które często bywają pomijane:

  • Samo wywołanie POST /api/auto/ nie blokuje slotu, ale każde wywołanie podrzędne Single, Proxy i Browser, które wykonuje w Twoim imieniu, już tak. Jedno wywołanie auto może zajmować więcej niż jeden slot w trakcie działania drabiny eskalacji. (W panelu wywołanie auto widnieje jako request, a jego wywołania podrzędne jako próby).
  • Żądanie Browser zajmuje slot przez cały czas renderowania strony, co trwa znacznie dłużej niż w przypadku żądania Single. Seria wywołań browser wyczerpie limit przy mniejszej liczbie żądań niż seria wywołań single.

Krok 1: Ogranicz własną współbieżność

Wybierz wartość poniżej limitu dla wywoływanego endpointu i jej nie przekraczaj. W puli workerów wystarczy do tego jedna linijka kodu:

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 to cały mechanizm. Pula nigdy nie ma otwartych więcej wywołań niż ta wartość, więc wsad może liczyć milion adresów URL i nadal mieścić się w limicie.

Zostaw margines. Jeśli dwa procesy po Twojej stronie korzystają z tego samego konta, nawet z różnymi kluczami API, dzielą jeden wspólny limit, więc przydziel każdemu z nich połowę. Jeśli szybki cel pozwala puli kończyć żądania szybciej, niż pozwala na to minuta, dostosuj tempo workerów, aby utrzymać się także poniżej limitu minutowego.

Krok 2: Zastosuj backoff zamiast ponawiać wysyłkę

Jeśli nadejdzie odmowa, złym rozwiązaniem jest natychmiastowe ponowne wysłanie wsadu. Każde wywołanie w nim zostanie ponownie odrzucone, a ponowienia nałożą się na żądania, które już były w toku.

Najpierw odczekaj. Czas oczekiwania znajduje się w nagłówku Retry-After oraz w retry_after_seconds w treści odpowiedzi:

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

Dodaj jitter, gdy uruchamiasz wielu workerów. Bez niego każdy worker odrzucony w tej samej sekundzie wznawia działanie w tej samej sekundzie i ponownie zostaje odrzucony razem z innymi.

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

Krok 3: To samo w 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;
}

Stała liczba workerów pobierających zadania z jednej kolejki utrzymuje dokładnie tyle otwartych wywołań, niezależnie od wielkości partii.

Krok 4: Obserwuj rzeczywiste zużycie

Otwórz Usage & Limits w dashboardzie. Liczniki na żywo obok limitów pokazują współbieżność, limit na minutę oraz liczbę requestów przeglądarkowych na dzień w czasie rzeczywistym, dzięki czemu możesz dobrać MAX_IN_FLIGHT na podstawie rzeczywistego przebiegu, a nie domysłów.

Activity Log rejestruje również odrzucenia. Przebieg, który zakończył się prawidłową liczbą wierszy, ale z rozproszonymi wynikami rate_limit, sam nakładał na siebie ograniczenia.

Częste błędy

  • Wysyłanie całej listy naraz. asyncio.gather na 5 000 adresów URL lub Promise.all na nieograniczonej tablicy otwiera 5 000 wywołań. Ogranicz pulę, a nie listę.
  • Ponawianie prób równolegle. Ponowne wysyłanie każdego odrzuconego wywołania w momencie jego odrzucenia odtwarza skok ruchu, który spowodował odmowę. Odczekaj czas wskazany w Retry-After i dodaj jitter.
  • Traktowanie limitu dziennego lub okresowego jako zwykłego oczekiwania. plan_limit_browser_daily resetuje się o północy UTC, a plan_limit_credits i plan_limit_bandwidth na koniec okresu rozliczeniowego. Zatrzymaj przebieg i odczytaj resets_at z treści odpowiedzi, jeśli jest dostępny.
  • Liczenie wywołań auto jako pojedynczego slotu. Mechanizm ladder wewnątrz /api/auto/ wykonuje rzeczywiste podwywołania i to one wliczają się do limitów.
  • Dzielenie klucza między procesy bez podziału budżetu. Limity są przypisane do konta, a nie do procesu.
  • Dobieranie jednej puli dla wszystkich endpointów. Single, Proxy i Browser mają własne limity współbieżności. Pula dobrana pod Single przekroczy mniejszy limit Browser.
Aktualizacja: 15 października 2026