Ejecutar requests en paralelo
Qué aprenderás
Cómo ejecutar un lote grande de requests de FourA de forma concurrente sin acumular errores 429, limitando cuántas llamadas mantienes abiertas y reaccionando ante un rechazo en lugar de repetirlo.
Requisitos previos
- Una API key de FourA (obtén una aquí)
- Python 3.9+ con
requests, o Node.js 18+
Los límites con los que trabajas
Tu plan incluye dos límites máximos por endpoint: cuántos requests pueden ejecutarse al mismo tiempo y cuántos pueden iniciarse por minuto (Browser tiene un límite diario en lugar de uno por minuto). Single, Proxy y Browser tienen cada uno sus propios valores, y la pestaña Limits & Features de Usage & Limits los enumera.
El request que supera el límite de concurrencia se devuelve como HTTP 429 con X-FourA-Limit: plan_limit_concurrency y el límite máximo en el 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
}
La que supera el límite por minuto devuelve X-FourA-Limit: plan_limit_rate, y retry_after_seconds indica cuánto tiempo esperar hasta que se admita una solicitud más (de 1 a 120 segundos).
Ambos rechazos son inmediatos. FourA no pone la llamada en cola para devolverla más tarde, por lo que no se gasta nada ni se factura nada. Una llamada rechazada no cuenta para el minuto deslizante, así que una cuenta que pide un poco por encima de su límite sigue obteniendo el límite completo. Una tormenta de reintentos recibe su propia respuesta: cuando las llamadas rechazadas siguen llegando a más del doble de tu límite por minuto, cada llamada en ese endpoint se rechaza con cooldown: true y retry_after_seconds: 30, así que haz una pausa durante esos 30 segundos. Referencia completa de campos: Rate Limits.
Dos detalles que a menudo se pasan por alto:
- Una llamada
POST /api/auto/no ocupa un slot por sí misma, pero cada subllamada Single, Proxy y Browser que realiza por ti sí lo hace. Una llamada auto puede ocupar más de un slot mientras se ejecuta su secuencia. (En tu panel, la llamada auto es la request; sus subllamadas son sus intentos). - Una request de Browser ocupa su slot durante todo el tiempo que tarda la página en renderizarse, lo cual es mucho más largo que una request de Single. Un lote de llamadas de browser alcanza su límite con menos requests que un lote de llamadas de single.
Paso 1: Limita tu propia concurrencia
Elige un número por debajo del límite para el endpoint al que estás llamando y mantenlo. Un worker pool hace esto en una sola línea:
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 es todo el mecanismo. El pool nunca tiene más llamadas abiertas que esa cantidad, por lo que el lote puede tener un millón de URLs de longitud y aun así mantenerse por debajo del límite.
Deja margen. Si dos procesos de tu lado usan la misma cuenta, incluso con diferentes API keys, comparten un mismo tope, así que asigna la mitad a cada uno. Si un objetivo rápido permite que el pool complete las requests más rápido de lo que un minuto permite, regula el ritmo de los workers para mantenerte también por debajo del tope por minuto.
Paso 2: Aplica backoff en lugar de reenviar
Si llega un rechazo, la respuesta incorrecta es reenviar el lote de inmediato. Cada llamada en él será rechazada de nuevo, y los reintentos se acumularán sobre las requests que ya se estaban ejecutando.
Espera primero. El tiempo de espera se encuentra en el header Retry-After y en retry_after_seconds dentro del body:
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"}
Añade jitter cuando ejecutes muchos workers. Sin él, cada worker rechazado en el mismo segundo se reactiva en el mismo segundo y vuelven a fallar juntos.
import random
time.sleep(wait + random.uniform(0, 0.5))
Paso 3: Lo mismo en 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 número fijo de workers consumiendo de una sola cola mantiene exactamente esa cantidad de llamadas abiertas, sin importar el tamaño del lote.
Paso 4: Monitorea lo que realmente estás usando
Abre Usage & Limits en el dashboard. Los contadores en tiempo real junto a tus límites muestran la concurrencia, la tasa por minuto y las browser requests por día a medida que cambian, para que puedas dimensionar MAX_IN_FLIGHT frente a una ejecución real en lugar de una estimación.
El Activity Log también registra los rechazos. Una ejecución que finalizó con la cantidad correcta de filas pero con resultados rate_limit dispersos se estuvo limitando a sí misma.
Errores comunes
- Lanzar toda la lista al mismo tiempo.
asyncio.gathersobre 5,000 URLs, oPromise.allsobre un array ilimitado, abre 5,000 llamadas. Limita el pool, no la lista. - Reintentar en paralelo. Reenviar cada llamada rechazada en el instante en que es rechazada reproduce el pico que causó el rechazo. Espera el
Retry-Aftery añade jitter. - Tratar una asignación diaria o de periodo como una simple espera.
plan_limit_browser_dailyse reinicia a la medianoche UTC,plan_limit_creditsyplan_limit_bandwidthal final del periodo de facturación. Detén la ejecución y leeresets_atdel body cuando esté disponible. - Contar las llamadas auto como un solo slot cada una. La secuencia interna de
/api/auto/realiza sub-llamadas reales, y esas son las que cuentan para los límites máximos. - Compartir una key entre procesos sin dividir el presupuesto. Los límites máximos son por cuenta, no por proceso.
- Dimensionar un solo pool para cada endpoint. Single, Proxy y Browser tienen cada uno su propio número de concurrencia. Un pool dimensionado para Single sobrepasa un límite más bajo de Browser.
Relacionado
- Rate Limits: Cada formato de rechazo y el significado de cada campo
- Response Headers:
X-FourA-LimityRetry-After - Request Outcomes: Por qué un resultado
rate_limitnunca se factura - Usage & Limits: Dónde están los contadores en tiempo real y los números de tu plan
- Smart Fetch (Auto): Cómo una llamada auto se convierte en varias sub-llamadas