La concurrencia, no los créditos, decide cuánto tarda tu job de scraping

El mismo job de 200.000 solicitudes tarda 22 horas con 5 slots concurrentes, 4,4 con 25. Por qué los planes limitan la concurrencia, y el worker pool correcto.

Dos cuentas pueden tener exactamente el mismo saldo de créditos y terminar el mismo job a velocidades radicalmente distintas. El número que decide cuánto tarda en ejecutarse un job por lotes es la concurrencia, cuántas solicitudes puedes tener en vuelo a la vez, y normalmente no es el número que la gente revisa primero.

Las cuentas

Supongamos un job de 200.000 solicitudes, y que cada una tarda unos 2 segundos de ida y vuelta, una media razonable para una página renderizada o una búsqueda con reintentos. El trabajo total es 400.000 segundos. Cuánto tarda en ejecutarse depende por completo de cuántos de esos segundos puedes gastar en paralelo:

Slots concurrentes Tiempo total
1 111,1 horas (4,6 días)
5 22,2 horas
10 11,1 horas
25 4,4 horas

Mismo job, mismo coste en créditos, una diferencia de 25 veces en tiempo real entre 1 slot y 25. Si un job nocturno tiene que terminar antes de que empiece la jornada laboral, la concurrencia es el número que hay que revisar antes de comprometerse con un horario, no el saldo de créditos.

Por qué los planes limitan la concurrencia, no solo los créditos

Los créditos miden cuánto trabajo puedes hacer en total. La concurrencia mide cuánto de ese trabajo puede pasar en el mismo instante, y existe por una razón distinta: cada solicitud en vuelo ocupa una sesión de navegador, una conexión proxy o ambas, en una infraestructura que cuesta dinero mantener aprovisionada, estés o no cerca de tu límite de créditos.

En FastAF, la concurrencia escala con el pack de créditos que compras: Free permite 2 solicitudes concurrentes, cada pack de pago 5 (Starter, Growth, Scale y Pro), Business 25 (octubre de 2026). Una cuenta del plan Free puede ejecutar dos solicitudes a la vez; una cuenta Business puede tener 25 abiertas simultáneamente. Enviar más solicitudes que la concurrencia de tu pack no las pone en cola educadamente para ir más rápido, empieza a devolver respuestas 429, así que merece la pena diseñar el job en torno a ese límite en vez de descubrirlo por ensayo y error.

Escribir un worker pool que respete el límite

El error que de verdad te cuesta tiempo es fijar max_workers de forma optimista y dejar que la lógica de reintentos disimule los 429 resultantes. Ponlo en la concurrencia real de tu pack y los 429 en su mayoría dejan de pasar directamente.

ThreadPoolExecutor, la opción más simple para la mayoría de scripts:

import time
import requests
from concurrent.futures import ThreadPoolExecutor

API_KEY = "faf_live_..."  # tu clave de FastAF
CONCURRENCY = 5  # ajusta a tu pack: Free 2, de Starter a Pro 5, Business 25

def fetch(url, max_retries=5):
    for attempt in range(max_retries):
        resp = requests.get(
            "https://api.fastaf.co/v1/scrape",
            params={"url": url},
            headers={"Authorization": f"Bearer {API_KEY}"},
            timeout=90,
        )
        if resp.status_code == 200:
            return resp.json()
        if resp.status_code == 429:
            wait = int(resp.headers.get("Retry-After", 2 ** attempt))
            time.sleep(wait)
            continue
        if resp.status_code in (502, 503, 504):
            time.sleep(2 ** attempt)  # fallo transitorio upstream, no se factura
            continue
        resp.raise_for_status()
    raise RuntimeError(f"gave up on {url} after {max_retries} attempts")

urls = [...]  # tus 200.000 URLs
results = []
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
    for result in pool.map(fetch, urls):
        results.append(result)

asyncio, si el resto del pipeline ya es async, con un semáforo haciendo el mismo trabajo que max_workers arriba:

import asyncio
import httpx

CONCURRENCY = 5
sem = asyncio.Semaphore(CONCURRENCY)

async def fetch(client, url, max_retries=5):
    async with sem:
        for attempt in range(max_retries):
            resp = await client.get(
                "https://api.fastaf.co/v1/scrape",
                params={"url": url},
                headers={"Authorization": f"Bearer {API_KEY}"},
                timeout=90,
            )
            if resp.status_code == 200:
                return resp.json()
            if resp.status_code == 429:
                wait = int(resp.headers.get("Retry-After", 2 ** attempt))
                await asyncio.sleep(wait)
                continue
            if resp.status_code in (502, 503, 504):
                await asyncio.sleep(2 ** attempt)
                continue
            resp.raise_for_status()
        raise RuntimeError(f"gave up on {url} after {max_retries} attempts")

async def run(urls):
    async with httpx.AsyncClient() as client:
        return await asyncio.gather(*(fetch(client, u) for u in urls))

results = asyncio.run(run(urls))

El semáforo mantiene exactamente el mismo techo de concurrencia que max_workers arriba; elige el estilo que encaje con el resto de tu código, no uno por razones de corrección.

Las dos cosas que de verdad causan 429 en la práctica

Workers por encima del límite del pack. La obvia: max_workers=50 en un pack Starter (5 concurrentes) significa que 45 de cada 50 solicitudes fallan de inmediato con 429, y la lógica de backoff solo lo hace lento en vez de rápido.

Un segundo proceso que olvidaste. Un cron job y una prueba manual ejecutándose contra la misma clave de API al mismo tiempo duplican tu concurrencia efectiva sin que ninguno de los dos procesos sepa del otro. Si ves 429 a pesar de tener max_workers bien configurado, revisa si hay un segundo script corriendo contra la misma clave antes de asumir que el límite cambió.

La versión corta

Antes de estimar cuánto tardará un job por lotes, revisa la concurrencia de tu pack, no solo su saldo de créditos: 2 para Free, 5 para Starter, Growth, Scale y Pro, 25 para Business a fecha de octubre de 2026. Ajusta el tamaño de tu worker pool a ese número, respeta Retry-After en los 429, y aplica backoff exponencial en los 5xx. El tiempo de ejecución de un job es (total de solicitudes x latencia media) / slots concurrentes, y la concurrencia es la única de esas tres variables que realmente puedes cambiar comprando un pack más grande.