La concorrenza, non i crediti, decide quanto dura un job di scraping
Lo stesso job da 200.000 richieste dura 22 ore a 5 slot concorrenti, 4,4 ore a 25. Perché i piani limitano la concorrenza, e un worker pool corretto in Python.

Due account possono avere esattamente lo stesso saldo di crediti e finire lo stesso job a velocità radicalmente diverse. Il numero che decide quanto dura un job batch è la concorrenza, quante richieste puoi tenere in volo contemporaneamente, ed è di solito il numero che si controlla per ultimo.
I numeri
Immagina un job da 200.000 richieste, ciascuna con un round trip di circa 2 secondi, una media ragionevole per una pagina renderizzata o una ricerca con retry. Il lavoro totale è 400.000 secondi. Quanto ci vuole a eseguirlo dipende interamente da quanti di quei secondi puoi spendere in parallelo:
| Slot concorrenti | Tempo totale |
|---|---|
| 1 | 111,1 ore (4,6 giorni) |
| 5 | 22,2 ore |
| 10 | 11,1 ore |
| 25 | 4,4 ore |
Stesso job, stesso costo in crediti, una differenza di 25 volte nel tempo reale tra 1 slot e 25. Se un job notturno deve finire prima che inizi la giornata lavorativa, la concorrenza è il numero da controllare prima di fissare uno schedule, non il saldo crediti.
Perché i piani limitano la concorrenza, non solo i crediti
I crediti misurano quanto lavoro puoi fare in totale. La concorrenza misura quanto di quel lavoro può avvenire nello stesso istante, ed esiste per una ragione diversa: ogni richiesta in volo tiene occupata una sessione browser, una connessione proxy o entrambe, su un’infrastruttura che costa denaro da mantenere provisionata indipendentemente dal fatto che tu sia vicino al tuo limite di crediti.
Su FastAF la concorrenza scala col pacchetto di crediti che compri: Free gira a 2 richieste concorrenti, ogni pacchetto a pagamento a 5 (Starter, Growth, Scale e Pro), Business 25 (ottobre 2026). Un account Free può eseguire due richieste alla volta; un account Business può averne 25 aperte simultaneamente. Mandare più richieste della concorrenza del tuo pacchetto non le mette in coda educatamente per andare più veloce, inizia a restituire risposte 429, quindi vale la pena progettare il job attorno a quel tetto invece di scoprirlo per tentativi.
Scrivere un worker pool che rispetta il tetto
L’errore che ti costa davvero tempo è impostare max_workers in modo ottimistico e lasciare che la logica di retry copra i 429 risultanti. Impostalo alla concorrenza reale del tuo pacchetto e i 429 smettono in gran parte di succedere alla radice.
ThreadPoolExecutor, l’opzione più semplice per la maggior parte degli script:
import time
import requests
from concurrent.futures import ThreadPoolExecutor
API_KEY = "faf_live_..." # la tua chiave FastAF
CONCURRENCY = 5 # adeguala al tuo pacchetto: Free 2, da 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) # errore temporaneo a monte, non fatturato
continue
resp.raise_for_status()
raise RuntimeError(f"rinuncio su {url} dopo {max_retries} tentativi")
urls = [...] # i tuoi 200.000 URL
results = []
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
for result in pool.map(fetch, urls):
results.append(result)
asyncio, se il resto della pipeline è già async, con un semaforo che fa lo stesso lavoro di max_workers sopra:
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"rinuncio su {url} dopo {max_retries} tentativi")
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))
Il semaforo mantiene esattamente lo stesso tetto di concorrenza di max_workers qui sopra; scegli lo stile che si allinea al resto del tuo codebase, non uno per ragioni di correttezza.
Le due cose che causano davvero i 429 in pratica
Worker sopra il tetto del pacchetto. Il caso ovvio: max_workers=50 su un pacchetto Starter (5 concorrenti) significa che 45 richieste su 50 falliscono subito con 429, e la logica di backoff si limita a rendere lento quello che dovrebbe essere veloce.
Un secondo processo che avevi dimenticato. Un cron job e un test manuale lanciati contro la stessa API key nello stesso momento raddoppiano la tua concorrenza effettiva senza che nessuno dei due processi lo sappia. Se vedi 429 nonostante max_workers sia impostato correttamente, controlla se c’è un secondo script in esecuzione contro la stessa chiave prima di dare per scontato che il tetto sia cambiato.
In breve
Prima di stimare quanto durerà un job batch, controlla la concorrenza del tuo pacchetto, non solo il saldo crediti: 2 per Free, 5 per Starter, Growth, Scale e Pro, 25 per Business a ottobre 2026. Imposta la dimensione del worker pool a quel numero, rispetta Retry-After sui 429, e fai backoff esponenziale sui 5xx. Il tempo di esecuzione di un job è (richieste totali x latenza media) / slot concorrenti, e la concorrenza è l’unica di queste tre variabili che puoi davvero cambiare comprando un pacchetto più grande.