Exécuter des requêtes en parallèle

Ce que vous allez apprendre

Comment exécuter simultanément un volume important de requests FourA sans accumuler d'erreurs 429, en limitant le nombre d'appels ouverts et en réagissant à un refus plutôt qu'en le répétant.

Prérequis

Les limites applicables

Votre forfait applique deux plafonds par endpoint : le nombre de requests exécutables simultanément, et le nombre pouvant démarrer par minute (Browser dispose d'un quota par jour au lieu d'un quota par minute). Single, Proxy et Browser ont chacun leurs propres quotas, et l'onglet Limits & Features de Usage & Limits les détaille.

La request qui dépasse le plafond de concurrence renvoie une erreur HTTP 429 avec X-FourA-Limit: plan_limit_concurrency et le plafond dans le corps de réponse :

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

Celle qui dépasse le plafond par minute renvoie X-FourA-Limit: plan_limit_rate avec retry_after_seconds courant jusqu'à la fin de la minute.

Les deux refus sont immédiats. FourA ne met pas l'appel en file d'attente pour le traiter plus tard, donc rien n'est dépensé et rien n'est facturé. Les appels refusés sont toutefois comptabilisés dans la minute glissante, de sorte qu'une tempête de retries prolonge son propre temps d'attente. Référence complète des champs : Rate Limits.

Deux éléments importants sont souvent ignorés :

  • Un appel POST /api/auto/ n'est pas comptabilisé en lui-même, mais chaque sous-appel Single, Proxy et Browser qu'il effectue pour vous l'est. Un seul appel auto peut occuper plusieurs slots pendant l'exécution de sa cascade.
  • Une requête Browser occupe son slot pendant toute la durée du rendu de la page, ce qui est bien plus long qu'une requête Single. Un lot d'appels browser atteint son plafond avec moins de requêtes qu'un lot d'appels single.

Étape 1 : Limitez votre propre concurrence

Choisissez un nombre inférieur au plafond de l'endpoint que vous appelez et maintenez-le. Un pool de workers effectue cela en une seule ligne :

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 constitue l'ensemble du mécanisme. Le pool n'a jamais plus d'appels ouverts que cette limite, le lot peut donc contenir un million d'URL tout en restant sous le plafond.

Conservez une marge de sécurité. Si deux processus de votre côté partagent une même clé API, ils partagent un même plafond : attribuez donc la moitié à chacun. Si une cible rapide permet au pool de terminer les requêtes plus vite qu'une minute ne l'autorise, régulez le rythme des workers pour rester également sous le plafond par minute.

Étape 2 : Temporiser au lieu de renvoyer

En cas de refus, la mauvaise approche consiste à renvoyer immédiatement le lot. Chaque appel qu'il contient sera de nouveau refusé, et les nouvelles tentatives s'accumuleront sur les requêtes déjà en cours d'exécution.

Attendez d'abord. La durée d'attente est indiquée dans l'en-tête Retry-After et dans retry_after_seconds au sein du corps :

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

Ajoutez du jitter lorsque vous exécutez de nombreux workers. Sans cela, chaque worker rejeté au cours de la même seconde se réveille à la même seconde et subit un nouveau rejet simultané.

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

Étape 3 : La même chose avec 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;
}

Un nombre fixe de workers consommant une file d'attente unique maintient exactement ce nombre d'appels ouverts, quelle que soit la taille du lot.

Étape 4 : Observez votre consommation réelle

Ouvrez Usage & Limits dans le tableau de bord. Les compteurs en direct à côté de vos limites affichent la concurrence, le débit par minute et les requêtes de navigateur par jour en temps réel, ce qui vous permet de dimensionner MAX_IN_FLIGHT sur la base d'une exécution réelle plutôt que d'une estimation.

Le Journal d'activité enregistre également les refus. Une exécution qui s'est terminée avec le bon nombre de lignes mais une dispersion de résultats rate_limit s'est auto-limitée.

Erreurs fréquentes

  • Lancer toute la liste d'un coup. asyncio.gather sur 5 000 URLs, ou Promise.all sur un tableau non limité, ouvre 5 000 appels. Limitez le pool, pas la liste.
  • Réessayer en parallèle. Renvoyer chaque appel refusé dès qu'il est refusé reproduit le pic qui a causé le refus. Attendez le Retry-After et ajoutez un délai aléatoire (jitter).
  • Traiter un quota journalier ou périodique comme une simple attente. plan_limit_browser_daily se réinitialise à minuit UTC, plan_limit_credits et plan_limit_bandwidth à la fin de la période de facturation. Arrêtez l'exécution et lisez resets_at dans le corps lorsqu'il est présent.
  • Compter les appels auto comme un seul emplacement chacun. L'escalade interne de /api/auto/ effectue de réels sous-appels, et ce sont eux que les plafonds comptabilisent.
  • Partager une clé entre plusieurs processus sans diviser le budget. Les plafonds sont par compte, pas par processus.
  • Dimensionner un pool unique pour tous les endpoints. Single, Proxy et Browser ont chacun leur propre limite de concurrence. Un pool dimensionné pour Single dépasse le plafond plus restreint de Browser.

Ressources associées

Mis à jour : 10 septembre 2026