Source: https://aiprimetech.io/it/blog/rate-limits-and-retries/ Giu 23, 2026 · 7 min · API # Gestire i limiti di frequenza e i nuovi tentativi nelle API degli LLM A Di [AI Prime Tech](https://aiprimetech.io/it/) · Con l'assistenza dell'AI Alle 09:12 di un martedì, la nostra pipeline di riepilogo sembrava funzionare bene: 96 worker attivi, latenza mediana del modello di circa 2.8 secondi, nessun aumento del tasso di errori 5xx. Poi un cliente ha caricato un arretrato di 38,000 ticket di assistenza. Nel giro di quattro minuti, la pipeline era «fuori servizio» senza che alcun server lo fosse. Stavamo raggiungendo contemporaneamente due limiti diversi: - requests_per_minute: troppe chiamate API - tokens_per_minute: un volume eccessivo di prompt e risposte La logica ingenua dei tentativi ha peggiorato la situazione. Ogni worker riceveva 429, attendeva un secondo e riprovava insieme agli altri. Questo generava un'ondata di tentativi, che produceva ulteriori 429, generando un'altra ondata. Il sistema aveva accidentalmente creato un attacco denial-of-service sincronizzato contro la propria quota. Gestire i limiti di frequenza degli LLM non significa semplicemente «aggiungere un backoff esponenziale». In produzione, bisogna considerare token, code, concorrenza, tentativi, idempotenza, fornitori di riserva e comportamento della fatturazione come un unico sistema. ## I due limiti che contano: RPM e TPM La maggior parte delle API LLM applica una combinazione di: - RPM: richieste al minuto - TPM: token al minuto - RPD / TPD: richieste o token al giorno - Limiti delle richieste simultanee: numero massimo di chiamate in corso - Limiti specifici per modello: limiti più alti per i modelli più piccoli, più bassi per i modelli di frontiera Il punto importante: RPM e TPM causano problemi diversi. Se invii molti prompt brevi, raggiungi prima il limite RPM. Se invii meno prompt ma di grandi dimensioni, raggiungi prima il limite TPM. Esempio: ``` Limit: - 600 requests/minute - 300,000 tokens/minute Workload A: - 1,000 short classification calls/minute - 200 tokens each - 200,000 tokens/minute total Result: - TPM is fine - RPM fails Workload B: - 100 long document analysis calls/minute - 5,000 tokens each - 500,000 tokens/minute total Result: - RPM is fine - TPM fails ``` Un'insidia comune: gli sviluppatori limitano la frequenza delle richieste, ma non i token. Questo funziona nell'ambiente di staging, dove i prompt sono brevi. Poi gli utenti in produzione incollano PDF, cronologie di chat, log o contratti legali, e il budget di token si esaurisce. In pratica, considero ogni chiamata LLM come una prenotazione di due risorse: ``` cost = { requests: 1, tokens: estimated_prompt_tokens + max_output_tokens } ``` Anche se il modello restituisce meno token di output rispetto a max_output_tokens, riservare il limite superiore evita di superare la capacità disponibile. ## Cosa succede davvero quando ignori il limite TPM Supponiamo di avere: ``` TPM limit: 120,000 Average request: 4,000 input tokens + 1,000 max output tokens Reserved cost: 5,000 tokens ``` La capacità massima effettiva non è «il maggior numero possibile di worker». È: ``` 120,000 / 5,000 = 24 requests per minute ``` Questo equivale in media a una richiesta ogni 2.5 secondi. Se esegui 100 worker, tutte le richieste del primo gruppo potrebbero partire correttamente se il fornitore consente picchi di traffico. Poi l'account raggiunge un limite invalicabile. Ogni richiesta successiva riceve 429, e i tentativi si accumulano. La soluzione non consiste semplicemente nel ridurre il numero di worker. Serve uno scheduler che tenga conto delle prenotazioni dei token. ## Un limitatore di frequenza minimale che tiene conto dei token Per un singolo processo, di solito basta un token bucket. La capacità viene ripristinata nel tempo e ogni richiesta deve acquisire sia uno slot per la richiesta sia uno slot per i token. Ecco un esempio semplificato in Python: ``` import asyncio import time from dataclasses import dataclass @dataclass class Bucket: capacity: float refill_per_second: float tokens: float updated_at: float def refill(self): now = time.monotonic() elapsed = now - self.updated_at self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_second) self.updated_at = now async def acquire(self, amount: float): while True: self.refill() if self.tokens >= amount: self.tokens -= amount return missing = amount - self.tokens sleep_for = missing / self.refill_per_second await asyncio.sleep(min(sleep_for, 1.0)) class LLMRateLimiter: def __init__(self, rpm: int, tpm: int): now = time.monotonic() self.request_bucket = Bucket( capacity=rpm, refill_per_second=rpm / 60, tokens=rpm, updated_at=now, ) self.token_bucket = Bucket( capacity=tpm, refill_per_second=tpm / 60, tokens=tpm, updated_at=now, ) self.lock = asyncio.Lock() async def acquire(self, estimated_tokens: int): async with self.lock: await self.request_bucket.acquire(1) await self.token_bucket.acquire(estimated_tokens) ``` Utilizzo: ``` estimated_tokens = prompt_tokens + max_output_tokens await limiter.acquire(estimated_tokens) response = await client.chat.completions.create( model="gpt-5.5", messages=messages, max_tokens=max_output_tokens, ) ``` Questo esempio è volutamente semplice. In un sistema distribuito, usa Redis, i lock consultivi di Postgres o una coda con invio centralizzato. Il principio fondamentale è lo stesso: limita sia il numero di richieste sia il budget di token prima di chiamare l'API. ## Tentativi: il backoff è necessario ma non sufficiente I tentativi sono utili in caso di: - Transitorie 429 risposte - Timeout di rete - Temporanei 500, 502, 503, oppure 504 errori - Sovraccarico lato fornitore - Reset delle connessioni I tentativi sono pericolosi in caso di: - Errori di convalida - Errori relativi alla lunghezza del contesto - Errori di autenticazione - Rifiuti dovuti alle politiche di sicurezza - Prompt errati che falliscono in modo deterministico - Chiamate a strumenti non idempotenti La politica dei tentativi dovrebbe prima classificare gli errori. | Tipo di errore | Riprovare? | Azione tipica | 429 rate_limit_exceeded | Sì | Applicare il backoff, rispettare gli header dei tentativi, ridurre la concorrenza | 500/502/503/504 | Sì | Applicare il backoff con jitter e un numero limitato di tentativi | Timeout prima del corpo della risposta | Forse | Riprovare con una chiave di idempotenza, se supportata | Lunghezza del contesto superata | No | Comprimere, suddividere in blocchi o instradare verso un modello con contesto più lungo | Chiave API non valida | No | Disabilitare la chiave, avvisare l'operatore | Rifiuto per motivi di contenuto o sicurezza | No | Restituire un errore controllato o riscrivere la richiesta | Effetto collaterale dello strumento già eseguito | No/Forse | Richiede una progettazione che garantisca l'idempotenza La forma di base del backoff: ``` import random def retry_delay(attempt: int, base=0.5, cap=30.0): exponential = min(cap, base * (2 ** attempt)) return random.uniform(0, exponential) ``` Quel random.uniform(0, exponential) è il jitter. Non ometterlo. Senza jitter: ``` 100 workers fail 100 workers sleep 1s 100 workers retry 100 workers fail 100 workers sleep 2s 100 workers retry ``` Con jitter: ``` 100 workers fail workers retry across a spread of 0-1s then 0-2s then 0-4s ``` Il secondo schema lascia al provider e alla tua coda il margine necessario per riprendersi. ### Rispetta gli header di retry quando sono presenti Molte API includono un'indicazione come: ``` Retry-After: 12 ``` oppure header di reset specifici del provider. Se l'API indica un intervallo di attesa preciso, usalo come limite minimo: ``` delay = max(header_retry_after_seconds, retry_delay(attempt)) await asyncio.sleep(delay) ``` In pratica, imposto anche un limite al tempo totale dedicato ai tentativi. Una richiesta di chat rivolta all'utente non dovrebbe continuare a riprovare silenziosamente per due minuti mentre l'utente fissa un indicatore di caricamento. Un processo batch in background può attendere molto più a lungo. Esempio di criterio: ``` { "interactive_chat": { "max_attempts": 3, "max_total_retry_seconds": 8 }, "batch_summarization": { "max_attempts": 8, "max_total_retry_seconds": 300 } } ``` ## Una coda è meglio di un assalto simultaneo dei worker Se il lavoro da svolgere supera la quota disponibile, i worker non dovrebbero chiamare tutti direttamente l'API LLM. Inserisci una coda prima dell'API. Un’architettura pratica: ``` App/API -> Job queue -> Rate-aware dispatcher -> Provider client -> Result store ``` Il dispatcher gestisce: - conteggio delle RPM - conteggio dei TPM - selezione del modello - criterio di retry - fallback su un altro provider - limiti di concorrenza - priorità della coda Questo rende visibile il sovraccarico. Invece di migliaia di attività che falliscono con 429, vedi aumentare la lunghezza della coda e il tempo di attesa stimato. Nei sistemi basati su Redis, preferisco separare i job «pronti per l'esecuzione» da quelli con «retry differito»: ``` llm:ready llm:delayed llm:dead_letter ``` Un job fallito non torna immediatamente in ready. Gli viene assegnato un timestamp per il tentativo successivo. Un piccolo scheduler riporta nella coda dei job pronti quelli per cui è arrivato il momento di riprovare. ``` { "job_id": "sum_7f13", "model": "claude-sonnet-4.6", "estimated_tokens": 6200, "attempt": 2, "not_before": "2026-02-03T09:14:22Z" } ``` Quel not_before impedisce le tempeste di retry. ## Il controllo della concorrenza non equivale alla limitazione della frequenza I limiti di concorrenza pongono un tetto al lavoro in corso. I limiti di frequenza pongono un tetto al lavoro svolto in un intervallo di tempo. Servono entrambi. Immagina che ogni chiamata richieda 20 secondi e che il tuo limite RPM sia 60. Un limitatore basato solo sulle RPM consente una richiesta al secondo. Dopo 20 secondi, hai circa 20 chiamate simultanee. Se la latenza sale a 90 secondi, lo stesso limite RPM genera 90 chiamate simultanee. Questo potrebbe superare i limiti del provider o delle risorse locali. Usa un semaforo per la concorrenza: ``` provider_concurrency = asyncio.Semaphore(20) async def call_model(job): async with provider_concurrency: await limiter.acquire(job.estimated_tokens) return await send_request(job) ``` Un'insidia comune: se acquisisci il semaforo troppo presto, i worker restano inattivi occupando preziosi slot di concorrenza mentre attendono i token del limitatore di frequenza. Di solito, conviene prima acquisire la capacità consentita dal limite di frequenza, poi acquisire uno slot di concorrenza immediatamente prima della chiamata di rete. Se l'attesa del limitatore può essere lunga, evita di occupare le risorse dei worker durante quell'attesa. ## Degradazione controllata: concentra la qualità dove conta Quando i limiti di frequenza si fanno sentire, hai altre opzioni oltre al fallimento. Per esempio, un assistente per il supporto clienti potrebbe ridurre il livello di servizio in questo modo: | Livello di pressione | Strategia | Impatto sull'utente | Normale | Usa il modello più potente tra quelli configurati | Qualità migliore | Lieve pressione sui TPM | Riduci max_tokens, accorcia il contesto recuperato | Risposte leggermente più brevi | Pressione moderata | Instrada le attività semplici verso un modello più piccolo | Minimo se la classificazione o l'instradamento sono semplici | Pressione elevata | Metti in coda i job non urgenti | Lavoro in background ritardato | Pressione estrema | Restituisci una risposta dalla cache o basata su un modello predefinito | Qualità inferiore, ma servizio disponibile Esempi concreti: - Instrada l'assegnazione di tag ai ticket verso Haiku 4.5, Qwen o una variante piccola di Llama invece di usare un modello di frontiera. - Usa Sonnet 4.6 per il ragionamento ordinario e riserva Opus 4.8 o GPT-5.5 ai percorsi di escalation. - Sposta l'analisi con contesto lungo su Fable 5 o Gemini 3 solo quando il prompt richiede davvero quella finestra di contesto. - Per i job batch, sospendi i tenant a bassa priorità prima di incidere sul traffico interattivo. Il compromesso è la complessità. Ogni percorso di fallback richiede test, osservabilità e accettazione da parte del team di prodotto. In alcuni ambiti, una risposta peggiore può essere più dannosa di una risposta ritardata. ## Fallback con più chiavi e più provider L'instradamento con più chiavi e più provider può migliorare la resilienza, ma è facile implementarlo male. Esistono tre schemi distinti: | Schema | Cosa risolve | Rischio | Più chiavi, stesso provider/account | Isolamento operativo | Può violare i termini del provider se usato per aggirare i limiti | Più account/regioni | Isolamento dei guasti | Maggiore complessità nella fatturazione e nella governance | Più provider/modelli | Disponibilità e flessibilità dei costi | Variabilità dell'output, incompatibilità dei prompt Non usare più chiavi per aggirare i limiti di nascosto. Usale per separazioni legittime: tenant, ambienti, prodotti o ambiti di fatturazione. Un criterio di instradamento chiaro potrebbe essere questo: ``` { "task": "support_ticket_summary", "primary": { "provider": "anthropic", "model": "claude-sonnet-4.6" }, "fallbacks": [ { "provider": "openai", "model": "gpt-5.5", "when": ["provider_5xx", "rate_limit_after_retry_budget"] }, { "provider": "google", "model": "gemini-3", "when": ["context_length_needed", "primary_unavailable"] } ], "degrade_to": { "provider": "open_model_cluster", "model": "qwen", "when": ["batch_low_priority"] } } ``` Se usi un aggregatore o una piattaforma come AI Prime Tech per accedere a più modelli, valgono comunque le stesse regole di ingegneria: tieni traccia esplicitamente dell'idempotenza, dei budget, delle condizioni di fallback e delle regole di fatturazione finale. ## Evitare la doppia fatturazione durante il fallback La parte difficile del fallback è sapere se la prima richiesta è stata completata. Considera questa sequenza: ``` Client sends request to Provider A Provider A processes it successfully Network connection drops before client receives response Client retries with Provider B Both providers bill Two completions may exist ``` Non puoi risolvere completamente questo problema dal lato client, a meno che il provider non supporti le chiavi di idempotenza o la consultazione dello stato delle richieste. Puoi però limitare i danni. ### Usa le chiavi di idempotenza quando disponibili Genera una chiave stabile per ogni operazione logica: ``` import hashlib import json def idempotency_key(task_name, tenant_id, input_payload): canonical = json.dumps(input_payload, sort_keys=True, separators=(",", ":")) digest = hashlib.sha256(canonical.encode()).hexdigest() return f"{tenant_id}:{task_name}:{digest}" ``` Memorizza l'operazione prima dell'invio: ``` { "operation_id": "acme:summarize:9d2a...", "status": "in_flight", "provider": "anthropic", "model": "claude-sonnet-4.6", "created_at": "2026-02-03T09:12:01Z" } ``` Poi aggiornala dopo il completamento: ``` { "operation_id": "acme:summarize:9d2a...", "status": "completed", "provider_request_id": "req_abc123", "usage": { "input_tokens": 4180, "output_tokens": 612 } } ``` Prima di riprovare altrove, controlla la tabella delle operazioni. Se un altro worker l'ha già completata, restituisci il risultato memorizzato. ### Distingui i tipi di timeout Non tutti i timeout sono uguali: - Timeout di connessione: probabilmente la richiesta non ha mai raggiunto il provider - Timeout di lettura prima degli header: esito incerto - Timeout durante lo streaming dei token: quasi certamente il provider ha elaborato la richiesta - Annullamento da parte del client: dipende dal fatto che l'annullamento sia stato propagato Nei casi incerti, preferisco un criterio di retry prudente: ``` interactive: - retry connect timeout - do not cross-provider retry after partial stream - show recoverable error if status unknown batch: - retry after reconciliation delay - prefer same provider with idempotency key - fallback only after retry budget expires ``` ### Gestisci lo streaming con attenzione Lo streaming migliora la latenza percepita, ma complica i tentativi successivi. Una volta inviato un output parziale all'utente, riprovare con un altro modello può produrre una continuazione diversa. Per le chat in streaming, di solito considero la «ricezione del primo token» come il punto oltre il quale non è più possibile riprovare in modo trasparente. Da quel momento, gli errori diventano visibili: ``` "The response was interrupted. Retry?" ``` È meno elegante di un ripristino silenzioso, ma evita di unire output incompatibili provenienti da modelli diversi. ## Osservabilità: le metriche davvero utili Serve più del solo tasso di successo. Monitora almeno: ``` llm_requests_total{provider,model,status} llm_tokens_reserved_total{provider,model} llm_tokens_used_total{provider,model} llm_retries_total{provider,model,error_type} llm_queue_depth{priority,task_type} llm_queue_wait_seconds{priority,task_type} llm_rate_limit_hits_total{provider,model,limit_type} llm_fallbacks_total{from_provider,to_provider,reason} llm_duplicate_suppressed_total{task_type} ``` Durante gli incidenti reali, il pannello più utile della dashboard spesso non è quello della latenza. È: ``` queue_wait_seconds p50 / p95 rate_limit_hits by provider and model tokens_reserved vs tokens_used fallback rate by reason ``` Se tokens_reserved è costantemente molto superiore a tokens_used, le tue prenotazioni sono troppo conservative. Se è troppo vicino all'utilizzo effettivo o inferiore, stai allocando più capacità di quella disponibile e raggiungerai inaspettatamente il limite TPM. ## Un wrapper pratico per i tentativi Ecco uno schema compatto per riprovare solo in caso di errori per cui è opportuno farlo: ``` import asyncio import random import time RETRYABLE_STATUS = {429, 500, 502, 503, 504} class LLMError(Exception): def __init__(self, status, message="", retry_after=None): self.status = status self.retry_after = retry_after super().__init__(message) def jittered_backoff(attempt, base=0.5, cap=20): return random.uniform(0, min(cap, base * (2 ** attempt))) async def call_with_retries(fn, max_attempts=4, max_elapsed=20): started = time.monotonic() for attempt in range(max_attempts): try: return await fn() except LLMError as error: elapsed = time.monotonic() - started if error.status not in RETRYABLE_STATUS: raise if attempt == max_attempts - 1 or elapsed >= max_elapsed: raise delay = jittered_backoff(attempt) if error.retry_after is not None: delay = max(delay, error.retry_after) remaining = max_elapsed - elapsed await asyncio.sleep(min(delay, remaining)) ``` Il wrapper è volutamente banale. La complessità dovrebbe risiedere nella classificazione, nella gestione delle code, nell'idempotenza e nell'instradamento, non in un ciclo magico che continua a riprovare all'infinito. ## Indicazioni pratiche - Modella i limiti di frequenza sia in termini di RPM sia di TPM. Una limitazione che tiene conto dei token evita sorprese dovute a prompt di grandi dimensioni. - Usa backoff esponenziale con jitter, e rispetta gli header per i nuovi tentativi quando il provider li fornisce. - Inserisci una coda e un dispatcher tra la tua applicazione e i provider di LLM per carichi di lavoro batch o ad alto volume. - Controllo la concorrenza separatamente dai limiti di frequenza; altrimenti i picchi di latenza possono creare troppe chiamate in corso. - Design degradazione controllata in modo intenzionale: modelli più piccoli, contesto più breve, attività in background accodate o risposte memorizzate nella cache. - Tratta fallback tra più provider come una funzionalità di affidabilità, non come un'autorizzazione a inviare indiscriminatamente richieste duplicate. - Usa chiavi di idempotenza e registri delle operazioni per ridurre gli addebiti doppi ed eliminare le risposte generate duplicate. - Sii trasparente con gli utenti quando una risposta in streaming fallisce dopo che sono già stati emessi dei token. - Crea dashboard incentrate su attesa in coda, prenotazione dei token, raggiungimento dei limiti di frequenza, nuovi tentativi e motivi del fallback. - Mantieni semplice la logica dei nuovi tentativi; il vero lavoro in produzione consiste nel definire i budget, pianificare e sapere quando non riprovare. [API](/it/blog/tag/api/) A [AI Prime Tech](https://aiprimetech.io/it/) AI Prime Tech pubblica questo blog. Gli articoli sono redatti con l'assistenza dell'AI; verifica nella documentazione ufficiale i dettagli specifici della versione, come gli ID dei modelli, i prezzi e i limiti, prima di farvi affidamento. Ottieni accesso all'API Claude a un costo inferiore Un'unica chiave API per Claude Opus 5.5, Sonnet 5, Haiku 4.5 e Fable 5.1, oltre a GPT-6 modelli. Pagamento a consumo, senza abbonamento. [Ottieni la tua chiave API →](/register) AI Prime Tech è un gateway API indipendente di terze parti. Claude™ e Anthropic® sono marchi di Anthropic, PBC. Non si intende suggerire alcuna affiliazione o approvazione.