Un client bloccato in un retry loop senza backoff può generare, in un minuto, più carico sulla tua API di quanto ne generino insieme altri diecimila client. Visto da te non sembra un attacco: sembra un bug dalla loro parte, che diventa un incidente dalla tua. Un rate limiter blocca quel client prima che arrivi a quel punto.
Cosa decide davvero un rate limiter?
A ogni richiesta, il limiter si pone una sola domanda: questa chiave ha fatto troppe richieste nella finestra attuale? Tre scelte trasformano quella domanda in qualcosa che puoi implementare.
La chiave è l’elemento su cui conti: un indirizzo IP, una API key, l’ID di un utente autenticato. Scegli quella sbagliata e finisci per bloccare utenti innocenti che condividono un’identità, oppure per lasciare che chi abusa davvero del sistema si nasconda tra loro.
La finestra e il limite hanno senso solo insieme. “100 richieste” non è un limite; 100 al secondo e 100 al giorno sono due API molto diverse.
L’azione è cosa succede quando la chiave ha superato la soglia. La maggior parte delle API rifiuta con 429 Too Many Requests. Un job in background o chi invia webhook tende invece a metterla in coda e smaltirla più tardi, che è il compito di una message queue (vedi Cos’è una Coda di Messaggi e Come Usarla).
Quale algoritmo, quale store, quali header: tutto questo è implementazione attorno a quest’unico controllo.
Quattro algoritmi di rate limiting e cosa sbaglia ognuno
Fixed window counter. Incrementa un contatore per la chiave e azzeralo quando l’orologio supera un confine, ad esempio ogni minuto allo scoccare del minuto. Costa un intero per chiave. Il difetto: un client che manda 100 richieste a 0:59 e altre 100 a 1:00 ha inviato 200 richieste in due secondi senza violare “100 al minuto” in nessuna delle due finestre. Al confine, una fixed window lascia passare fino al doppio del rate dichiarato.
Sliding window log. Memorizza il timestamp di ogni richiesta per la chiave (un sorted set di Redis si presta bene), e a ogni nuova richiesta conta quante cadono negli ultimi 60 secondi. È esatto, quindi il trucco del confine non funziona. Il costo è una memoria che cresce con il traffico: contare un milione di timestamp per chiave non è come leggere un intero, lo stesso scarto tra scorrere una lista e leggere un indice a cui Notazione Big O Spiegata con Esempi di Codice dà un nome.
Sliding window counter. È quello che la maggior parte delle API dovrebbe usare. Tieni due contatori a finestra fissa, quello attuale e quello precedente, e stima il conteggio sliding come conteggio_attuale + conteggio_precedente * (frazione di sovrapposizione). Se sei al 40% del minuto attuale, conti tutte le richieste nella finestra attuale più il 60% di quella precedente. Due interi per chiave, nessun exploit sul confine, e abbastanza vicino all’esatto per i limiti reali.
Token bucket. Un bucket contiene fino a capacity token e si riempie a un rate fisso al secondo. Ogni richiesta consuma un token; senza token, nessuna richiesta. Un client può fare un burst fino a capacity richieste in una volta, poi viene limitato al rate di ricarica. Corrisponde a come browser e app chiamano davvero le API: il caricamento di una pagina spara otto richieste insieme, poi niente per un po’. Un limite fisso al secondo rifiuterebbe metà di quel caricamento.
Leaky bucket. Stesso bucket, obiettivo opposto. Le richieste in arrivo entrano in una coda ed escono a un rate costante, così i burst si trasformano in un flusso regolare. Il token bucket decide quanto burst è permesso a un client. Il leaky bucket decide quanto è costante il rate in uscita. Usalo quando ciò che sta a valle, una API di terze parti con una sua quota o un worker pool di dimensione fissa, ha bisogno di un flusso costante.
Come costruire un rate limiter con Redis
Il contatore deve vivere in un posto che ogni istanza della tua API può vedere. Un contatore in-process funziona finché hai una sola istanza; con una seconda, ognuna applica il limite solo a metà del traffico. Redis è lo store più usato (vedi Come Funziona il Caching con Redis e Come Usarlo se non ne hai ancora configurato uno), e il motivo sono i suoi comandi atomici più della sua velocità.
Un fixed window counter in Python con INCR e EXPIRE:
| |
INCR è atomico su una singola istanza Redis, quindi due richieste arrivate nello stesso microsecondo ottengono comunque due conteggi distinti. Il bug che la maggior parte dei limiter scritti a mano si porta dietro è un GET seguito da un SET. Due richieste concorrenti leggono lo stesso conteggio, decidono entrambe di essere sotto il limite, e passano entrambe. Resta un buco nella versione sopra: se il tuo processo muore tra INCR e EXPIRE, la chiave non scade mai e quel client resta bloccato. Spostare entrambe le chiamate in uno script Lua, come il token bucket qui sotto, chiude il buco.
Il token bucket richiede tre passi: calcolare il refill, prendere un token, scrivere lo stato. Nessun comando Redis singolo fa tutte le tre cose, quindi li esegui come script Lua tramite EVAL, che Redis esegue come un’unica unità atomica:
| |
Si chiama con redis.eval(script, 1, f"bucket:{key}", capacity, refill_rate, time.time()). Lo script ricarica il bucket per il tempo trascorso, prende un token nella stessa passata, e imposta un TTL abbastanza lungo da permettere un refill completo, così un client che smette di chiamare l’API non lascia il suo bucket a occupare spazio in Redis per sempre. Nessun’altra richiesta può leggere o modificare il bucket mentre lo script è in esecuzione.
Quali header inviare con una risposta 429
Una richiesta rifiutata dovrebbe dire al client quando riprovare.
Usa 429 Too Many Requests, non 403. Il client non è vietato, è solo in anticipo.
Aggiungi Retry-After: 30, in secondi o come data HTTP. Molte librerie client e SDK HTTP lo leggono per pianificare il retry, e quelle che non lo fanno danno almeno a una persona qualcosa su cui basarsi.
Invia X-RateLimit-Limit, X-RateLimit-Remaining e X-RateLimit-Reset su ogni risposta, non solo su quelle rifiutate. Dicono al client il tetto massimo, la quota rimasta e quando si resetta la finestra. È una convenzione di fatto (l’API di GitHub la usa) più che uno standard, ma è quello che gli sviluppatori client cercano. Un client ben fatto osserva Remaining scendere e rallenta prima di arrivare a un 429.
Esiste una versione standard in bozza: i campi RateLimit e RateLimit-Policy del draft-ietf-httpapi-ratelimit-headers dell’IETF. È un Internet-Draft da diversi anni e non è ancora diventato un RFC. Spedisci gli header X-RateLimit-* ora, e aggiungi i campi standard quando la bozza diventerà un RFC.
Errori comuni nel rate limiting
Limitare solo per IP. Una rete aziendale, il NAT di un operatore mobile o un edge CDN possono mettere migliaia di utenti reali dietro un solo indirizzo. Basa il limite sulla API key o sull’ID utente autenticato ovunque tu ne abbia uno, e usa l’IP solo come fallback per il traffico anonimo. JWT vs Sessione: Quale Autenticazione Scegliere spiega come si stabilisce quell’identità.
Un contatore in memoria dietro un load balancer. Ogni istanza conta per conto suo, quindi un limite di 100 diventa 100 × il numero di istanze. Appena scali orizzontalmente, il contatore deve stare in Redis, non in un dict locale al processo.
Un solo limite per tutta l’API. Una ricerca e un export massivo non costano la stessa cosa da servire. Imposta limiti per rotta, o almeno per classe di costo.
Zero tolleranza per i burst. Un retry di webhook, un import massivo, un’app mobile che si riconnette dopo un tunnel: per un limite fisso al secondo sembrano tutti abuso. Il token bucket esiste proprio per questo tipo di traffico.
Quale rate limiter costruire per primo?
Per una API tipica a lettura intensiva, costruisci un sliding window counter basato sulla API key, memorizzato in Redis, con gli header X-RateLimit-* su ogni risposta. Evita il burst al confine della fixed window e il costo in memoria di un log completo delle richieste. Aggiungi un token bucket solo sulle rotte dove i client inviano legittimamente richieste in batch (invio di webhook, upload massivi), e imposta la sua capacity sulla dimensione del batch più grande che ti aspetti, non sul rate a regime.