Retry, timeout e rate limit: come far sopravvivere il tuo codice agli errori delle API

Lo script gira perfettamente da tre settimane. Ogni notte elabora qualche centinaio di documenti e al mattino trovi i riassunti pronti. Poi una notte lo allarghi a cinquemila documenti, lo lasci andare e vai a dormire. Al mattino trovi un file di log di quattromila righe tutte uguali, un errore ripetuto all'infinito e quasi nessun riassunto. Il codice non era sbagliato: era ottimista. Aveva assunto che ogni chiamata all'API sarebbe riuscita.

Questa è la differenza tra un esperimento e uno strumento. Le API dei modelli sono servizi di rete condivisi da moltissime persone, e come tutti i servizi di rete a volte dicono "non adesso". In questa guida impari a leggere quei "non adesso", a distinguere quelli che vale la pena riprovare da quelli che non passeranno mai, e a scrivere una funzione di chiamata che regge. Gli esempi usano Python, ma il ragionamento vale per qualsiasi linguaggio e per qualsiasi fornitore.

Gli errori si dividono in due famiglie

La prima cosa da capire è che non tutti gli errori sono uguali. Se ripeti alla cieca una richiesta che non può riuscire, sprechi tempo e a volte soldi. Se ti arrendi alla prima difficoltà temporanea, getti via un lavoro che sarebbe riuscito un secondo dopo. La distinzione è tra errori permanenti ed errori transitori.

Gli errori permanenti dipendono da te: la richiesta è malformata (400), la chiave è sbagliata o revocata (401), non hai i permessi per quella risorsa (403), l'endpoint o il modello non esiste (404), la richiesta è troppo grande (413). Riprovare non cambia nulla, perché la richiesta sarà identica e identico sarà il rifiuto. Qui si corregge il codice o la configurazione.

Gli errori transitori dipendono dalle condizioni del momento. Il 429 significa che hai superato un limite di velocità, ed è il più frequente quando passi da pochi test a un carico vero. Il 500 è un errore interno imprevisto del servizio. Il 529 (nella documentazione Anthropic, overloaded_error) dice che l'API è temporaneamente sovraccarica, di solito per l'alto traffico complessivo. Il 504 indica che la richiesta è scaduta mentre veniva elaborata. Con tutti questi, la risposta giusta è aspettare e riprovare, ma con metodo.

C'è un caso insidioso a cavallo tra le due famiglie: un 429 non è sempre transitorio. Se il rifiuto nasce dal raggiungimento del tetto di spesa mensile del tuo piano, nessuna attesa breve lo risolverà: Anthropic precisa che in quel caso non c'è l'intestazione retry-after e le richieste continuano a fallire finché l'accesso non riprende o il limite non viene alzato. Per questo i tentativi devono avere sempre un tetto, come vedremo.

Cosa sono davvero i rate limit

I limiti di velocità si misurano di solito su più dimensioni insieme: richieste al minuto (RPM) e token al minuto, talvolta distinti tra token in ingresso e in uscita. Basta saturarne una per ricevere un 429, anche se le altre sono lontane dal tetto. Venti richieste piccolissime possono esaurire un limite di venti richieste al minuto molto prima di sfiorare il limite sui token.

C'è poi un dettaglio che sorprende chi si aspetta orologi precisi: il limite non si azzera ogni minuto sul secondo zero. Anthropic descrive un sistema a "secchio di token" che si riempie in modo continuo, quindi un limite di 60 richieste al minuto può essere applicato di fatto come una richiesta al secondo, e una raffica breve può scattare l'errore anche se il totale al minuto è rispettato. Esistono infine limiti di "accelerazione": un salto improvviso di traffico può causare 429 anche sotto le soglie ufficiali. La regola pratica è la stessa per tutti: aumenta il carico gradualmente e tieni un ritmo costante.

Il retry giusto: backoff esponenziale con jitter

Il retry ingenuo è un ciclo che riprova subito. È la ricetta peggiore: se il servizio ti ha respinto perché sovraccarico, bombardarlo di nuove richieste peggiora le cose, e le richieste fallite contano comunque per i tuoi limiti. Il metodo standard ha tre ingredienti.

Backoff esponenziale. Aspetti un tempo che raddoppia a ogni fallimento: un secondo, poi due, poi quattro, poi otto. Più il problema persiste, più ti fai da parte.

Jitter. Se centinaia di client ricevono lo stesso errore nello stesso istante e tutti aspettano esattamente due secondi, riprovano tutti insieme e si scontrano di nuovo. Aggiungere una componente casuale all'attesa spezza la sincronia. Lo mostra bene un classico dell'AWS Architecture Blog sul tema: a 100 client in competizione, il jitter ha ridotto le chiamate di oltre la metà e ha fatto finire il lavoro prima del solo backoff. La variante più semplice e solida, il full jitter, sceglie l'attesa a caso tra zero e il valore massimo corrente.

Un tetto. Limiti sia al numero di tentativi sia al tempo totale. Cinque tentativi con attese crescenti coprono quasi tutti i problemi passeggeri; dopo, è meglio fallire in modo pulito e chiaro che restare appesi.

E se il server ti dice quanto aspettare, ascoltalo. Molte risposte 429 includono l'intestazione retry-after, i secondi minimi da attendere prima di riprovare: riprovare prima è inutile, perché fallirà di nuovo. Trattala come un pavimento, non come un obiettivo: aspetta almeno quel tempo, aggiungendo il tuo jitter.

Attenzione al retry doppio

Un trucco che salva molte serate: gli SDK ufficiali fanno già retry per conto tuo. Secondo la documentazione di Anthropic, riprovano automaticamente gli errori transitori (connessione, rate limit, errori 5xx) con backoff esponenziale, due volte per impostazione predefinita, e rispettano retry-after. L'SDK di OpenAI si comporta in modo analogo con 429 e 503. Le opzioni per cambiare questo comportamento si impostano alla creazione del client (in Python max_retries).

Il rischio è costruire un secondo strato di retry sopra il primo: due cicli annidati moltiplicano i tentativi, e un errore che pensavi di riprovare cinque volte ti trattiene a lungo e martella il servizio più del previsto. Decidi chi comanda. Per un uso semplice, lascia fare all'SDK e alza eventualmente il numero di tentativi. Se vuoi il controllo (logging di ogni tentativo, tetto sul tempo totale, comportamento diverso per errore), disattiva i retry dell'SDK con max_retries=0 e gestiscili tu.

Timeout: una richiesta senza scadenza è un blocco

Il secondo pezzo del kit è il timeout. Una chiamata che resta appesa per sempre blocca l'intero programma, e di notte nessuno se ne accorge. Imposta sempre un tempo massimo di attesa ragionevole per il tuo caso d'uso. Quando però chiedi risposte molto lunghe, il timeout da solo non basta. La documentazione Anthropic consiglia, per richieste che possono superare i dieci minuti, di usare lo streaming (il modello restituisce il testo a pezzi mentre lo genera) oppure la Batch API; tenere aperta una singola connessione silenziosa a lungo è fragile, perché alcune reti chiudono i collegamenti inattivi. Un dettaglio utile: con lo streaming la risposta può comunque contenere un errore dopo che lo stato HTTP era già 200, quindi anche lì serve gestire gli errori.

La funzione che scrivi una volta sola

Mettiamo insieme i pezzi. Ecco una funzione di chiamata robusta con l'SDK Python di Anthropic (con quello di OpenAI cambiano i nomi delle eccezioni, non la logica). Disattiva i retry dell'SDK, per non averne due strati, e gestisce tutto in un punto.

import random
import time
import logging
import anthropic

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("llm")

# Disattivo i retry dell'SDK: li gestisco io, in un punto solo.
client = anthropic.Anthropic(max_retries=0, timeout=60.0)

MODELLO = "il-modello-che-usi"   # sostituisci con un modello a cui hai accesso
MAX_TENTATIVI = 5
TETTO_ATTESA = 30.0              # secondi

def chiamata_robusta(messaggi, max_tokens=1024):
    for tentativo in range(1, MAX_TENTATIVI + 1):
        inizio = time.time()
        try:
            risposta = client.messages.create(
                model=MODELLO,
                max_tokens=max_tokens,
                messages=messaggi,
            )
            log.info("ok tentativo=%d durata=%.1fs in=%d out=%d",
                     tentativo, time.time() - inizio,
                     risposta.usage.input_tokens, risposta.usage.output_tokens)
            return risposta

        except anthropic.APIStatusError as e:
            transitorio = e.status_code == 429 or e.status_code >= 500
            if not transitorio or tentativo == MAX_TENTATIVI:
                log.error("fallita status=%s request_id=%s",
                          e.status_code, getattr(e, "request_id", None))
                raise
            # Se il server indica quanto aspettare, è il minimo.
            minimo = float(e.response.headers.get("retry-after", 0))
            attesa = min(TETTO_ATTESA, 2 ** tentativo)
            attesa = max(minimo, random.uniform(0, attesa))  # full jitter

        except (anthropic.APIConnectionError, anthropic.APITimeoutError):
            if tentativo == MAX_TENTATIVI:
                raise
            attesa = random.uniform(0, min(TETTO_ATTESA, 2 ** tentativo))

        log.warning("riprovo tra %.1fs (tentativo %d)", attesa, tentativo)
        time.sleep(attesa)

Leggila con calma, perché ogni riga ha un motivo. Gli errori permanenti (400, 401, 403...) escono subito, senza ritentare. Quelli transitori aspettano con backoff e jitter, rispettando retry-after come minimo. Gli errori di connessione e i timeout vengono trattati come transitori. Ogni chiamata lascia una riga di log con durata e token, e ogni fallimento definitivo registra il request_id, l'identificativo che l'API restituisce in ogni risposta e che il supporto ti chiederà se devi segnalare un problema. Quel log è anche, senza che tu faccia altro, il tuo contatore dei costi.

Quando il volume è davvero grande, c'è una strada ancora più semplice: se il lavoro non è urgente, la Batch API ti evita quasi del tutto il problema dei rate limit sincroni, perché le richieste vengono messe in coda ed elaborate in differita, a prezzo ridotto. Rispetto alle chiamate sincrone cambia il modo di lavorare: spedisci e poi recuperi i risultati, invece di aspettare ogni risposta.

Gli errori da non fare

Il più comune: riprovare tutto, anche i 400 e i 401. Sembra prudenza, in realtà è rumore nei log e tempo perso. Il secondo: nessun tetto, quindi un errore permanente travestito da transitorio (come il tetto di spesa) ti tiene in loop per ore. Il terzo: più worker in parallelo che condividono lo stesso limite senza saperlo, per cui ognuno fa i conti come se avesse l'API tutta per sé. Se lanci tanti job insieme, regola la concorrenza (una coda con un numero massimo di chiamate contemporanee) invece di affidarti ai soli retry. Il quarto: impostare max_tokens enorme "per sicurezza" su un fornitore dove conta nei calcoli dei limiti; mettilo vicino alla lunghezza realmente attesa. Controlla sempre nella documentazione del tuo fornitore come viene conteggiato.

Prova subito

Prendi la tua chiamata API più semplice e avvolgila in chiamata_robusta(). Poi mettila alla prova con cattiveria, in tre esperimenti da quindici minuti. Primo: cambia la chiave API con un valore sbagliato e verifica che l'errore 401 esca subito, senza nessun tentativo. Secondo: abbassa timeout a 0.001 secondi e guarda i tentativi, le attese crescenti e il fallimento finale nel log; nota che le attese non sono mai uguali due volte, ed è il jitter. Terzo: lancia in un ciclo cinquanta chiamate molto brevi e osserva se incontri qualche 429; se succede, controlla l'intestazione retry-after nella risposta e verifica che la tua funzione la rispetti.

Alla fine tieni quella funzione come tuo standard: la scrivi una volta, la usi per sempre, e il tuo script notturno la prossima volta non ti lascerà quattromila righe uguali.