Spiegazione del Throttling: Una Guida per Gestire i Limiti delle Richieste API

Spiegazione del Throttling: Una Guida per Gestire i Limiti delle Richieste API

Scritto da Francesco Di Donato 4 dicembre 2024 8 minuti di lettura

Quando Dovresti Implementare il Throttling nel Tuo Codice?

Per progetti di grandi dimensioni, è solitamente meglio usare strumenti come [Cloudflare Rate Limiting][cloudflare-rate-limiting] o [HAProxy][haproxy-traffic-policing]. Questi sono potenti, affidabili e si occupano del lavoro pesante per te.

Ma per progetti più piccoli—o se vuoi imparare come funzionano le cose—puoi creare il tuo rate limiter direttamente nel codice. Perché?

  • È Semplice: Costruirai qualcosa di diretto e facile da capire.
  • È Economico: Nessun costo aggiuntivo oltre all’hosting del tuo server.
  • Funziona per Piccoli Progetti: Finché il traffico è basso, mantiene le cose veloci ed efficienti.
  • È Riutilizzabile: Puoi copiarlo in altri progetti senza dover configurare nuovi strumenti o servizi.

Cosa Imparerai

Alla fine di questa guida, saprai come costruire un throttler di base in [TypeScript][typescript] per proteggere le tue API dal sovraccarico. Ecco cosa tratteremo:

  • Limiti di Tempo Configurabili: Ogni tentativo bloccato aumenta la durata del blocco per prevenire abusi.
  • Tetti alle Richieste: Imposta un numero massimo di richieste consentite. Questo è particolarmente utile per API che coinvolgono servizi a pagamento, come OpenAI.
  • Archiviazione in Memoria: Una soluzione semplice che funziona senza strumenti esterni come Redis—ideale per piccoli progetti o prototipi.
  • Limiti per Utente: Traccia le richieste su base per utente usando il loro indirizzo IPv4. Sfrutteremo [SvelteKit][sveltekit-request-event] per recuperare facilmente l’IP del client con il suo [metodo integrato][expose-event-clientAddress].

Questa guida è pensata per essere un punto di partenza pratico, perfetto per gli sviluppatori che vogliono imparare le basi senza complessità inutili. Ma non è pronta per la produzione.

Prima di iniziare, voglio dare i giusti crediti alla [sezione Rate Limiting di Lucia][lucia].


Implementazione del Throttler

Definiamo la classe Throttler:

export class Throttler {
	private storage = new Map<string, ThrottlingCounter>();

	constructor(private timeoutSeconds: number[]) {}
}

Il costruttore di Throttler accetta una lista di durate di timeout (timeoutSeconds). Ogni volta che un utente viene bloccato, la durata aumenta progressivamente in base a questa lista. Alla fine, quando viene raggiunto il timeout finale, potresti anche attivare una callback per bannare permanentemente l’IP dell’utente—anche se questo va oltre lo scopo di questa guida.

Ecco un esempio di creazione di un’istanza di throttler che blocca gli utenti per intervalli crescenti:

const throttler = new Throttler([1, 2, 4, 8, 16]);

Questa istanza bloccherà gli utenti la prima volta per un secondo. La seconda volta per due, e così via.

Usiamo una Map 🔗 per memorizzare gli indirizzi IP e i loro dati corrispondenti. Una Map è ideale perché gestisce in modo efficiente aggiunte e cancellazioni frequenti.

Suggerimento Pro: Usa una Map per dati dinamici che cambiano frequentemente. Per dati statici e immutabili, un oggetto è meglio. (Una tana del coniglio 1)


Quando il tuo endpoint riceve una richiesta, estrae l’indirizzo IP dell’utente e consulta il Throttler per determinare se la richiesta debba essere consentita.

Come Funziona

  • Caso A: Utente Nuovo o Inattivo     Se l’IP non viene trovato nel Throttler, è o la prima richiesta dell’utente o sono stati inattivi abbastanza a lungo. In questo caso:

  - Consenti l’azione.   - Traccia l’utente memorizzando il suo IP con un timeout iniziale.

  • Caso B: Utente Attivo     Se l’IP viene trovato, significa che l’utente ha fatto richieste precedenti. Qui:   - Controlla se il tempo di attesa richiesto (basato sull’array timeoutSeconds) è trascorso dal loro ultimo blocco.   - Se è passato abbastanza tempo:     - Aggiorna il timestamp.     - Incrementa l’indice del timeout (limitato all’ultimo indice per prevenire overflow).   - Altrimenti, nega la richiesta.

In quest’ultimo caso, dobbiamo verificare se è passato abbastanza tempo dall’ultimo blocco. Sappiamo a quale dei timeoutSeconds dovremmo fare riferimento grazie a un index. Se no, semplicemente respingiamo la richiesta. Altrimenti, aggiorniamo il timestamp.

export class Throttler {
	// ...

	public consume(key: string): boolean {
		const counter = this.storage.get(key) ?? null;
		const now = Date.now();

		// Caso A
		if (counter === null) {
			// Alla prossima richiesta, verrà trovato.
			// L'indice 0 di [1, 2, 4, 8, 16] restituisce 1.
			// Questa è la quantità di secondi che dovrà attendere.
			this.storage.set(key, {
				index: 0,
				updatedAt: now
			});
			return true; // consentito
		}

		// Caso B
		const timeoutMs = this.timeoutSeconds[counter.index] * 1000;
		const allowed = now - counter.updatedAt >= timeoutMs;
		if (!allowed) {
			return false; // negato
		}

		// Permetti la chiamata, ma incrementa il timeout per le richieste successive.
		counter.updatedAt = now;
		counter.index = Math.min(counter.index + 1, this.timeoutSeconds.length - 1);
		this.storage.set(key, counter);

		return true; // consentito
	}
}

Quando si aggiorna l’indice, questo viene limitato all’ultimo indice di timeoutSeconds. Senza questo, counter.index + 1 supererebbe i limiti dell’array e il successivo accesso a this.timeoutSeconds[counter.index] risulterebbe in un errore a runtime.

Esempio di Endpoint

Questo esempio mostra come usare il Throttler per limitare la frequenza con cui un utente può chiamare la tua API. Se l’utente fa troppe richieste, riceverà un errore invece di eseguire la logica principale.

const throttler = new Throttler([1, 2, 4, 8, 16, 30, 60, 300]);

export async function GET({ getClientAddress }) {
	const IP = getClientAddress();

	if (!throttler.consume(IP)) {
		throw error(429, { message: 'Too Many Requests' });
	}

	// Leggi dal DB, chiama OpenAI - fai l'operazione.

	return new Response(null, { status: 200 });
}

Nota per l’Autenticazione

Quando si utilizza il rate limiting con i sistemi di login, potresti riscontrare questo problema:

  1. Un utente effettua il login, attivando il Throttler che associa un timeout al suo IP.
  2. L’utente si disconnette o la sua sessione termina (es. si disconnette immediatamente, il cookie di sessione scade e il browser si chiude, ecc.).
  3. Quando tenta di accedere di nuovo poco dopo, il Throttler potrebbe ancora bloccarlo, restituendo un errore 429 Too Many Requests.

Per evitare ciò, usa l’ID univoco dell’utente (userID) invece del suo IP per il rate limiting. Inoltre, devi resettare lo stato del throttler dopo un login riuscito per evitare blocchi non necessari.

Aggiungi un metodo reset alla classe Throttler:

export class Throttler {
	// ...

	public reset(key: string): void {
		this.storage.delete(key);
	}
}

E usalo dopo un login andato a buon fine:

const user = db.get(email);

if (!throttler.consume(user.ID)) {
	throw error(429);
}

const validPassword = verifyPassword(user.password, providedPassword);
if (!validPassword) {
	throw error(401);
}

throttler.reset(user.id); // Azzera il throttling per l'utente

Gestire Record IP Obsoleti con Pulizia Periodica

Mentre il tuo throttler traccia gli IP e i limiti di richiesta, è importante pensare a come e quando rimuovere i record IP che non sono più necessari. Senza un meccanismo di pulizia, il tuo throttler continuerà a memorizzare record in memoria, portando potenzialmente a problemi di prestazioni nel tempo man mano che i dati crescono.

Per evitare ciò, puoi implementare una funzione di pulizia che rimuove periodicamente i vecchi record dopo un certo periodo di inattività. Ecco un esempio di come aggiungere un semplice metodo di pulizia per rimuovere le voci obsolete dal throttler.

export class Throttler {
	// ...

	public cleanup(): void {
        const now = Date.now()

        // Cattura prima le chiavi per evitare problemi durante l'iterazione (usiamo .delete)
        const keys = Array.from(this.storage.keys())

        for (const key of keys) {
            const counter = this.storage.get(key)
            if (!counter) {
                // Salta se il contatore è già stato eliminato (gestisce problemi di concorrenza)
                return
            }

            // Se l'IP è al primo timeout, rimuovilo dalla memoria
            if (counter.index == 0) {
                this.storage.delete(key)
                continue
            }

            // Altrimenti, riduci l'indice di timeout e aggiorna il timestamp
            counter.index -= 1
            counter.updatedAt = now
            this.storage.set(key, counter)
        }
    }
}

Un modo molto semplice (ma probabilmente non il migliore) per schedulare la pulizia è con setInterval:

const throttler = new Throttler([1, 2, 4, 8, 16, 30, 60, 300])
const oneMinute = 60_000
setInterval(() => throttler.cleanup(), oneMinute)

Con una pulizia periodica, eviti il sovraccarico della memoria e ti assicuri che gli utenti che non hanno tentato di fare richieste da un po’ non vengano più tracciati - questo è un primo passo per rendere il tuo sistema di rate-limiting sia scalabile che efficiente dal punto di vista delle risorse.


Footnotes

  1. Se ti senti avventuroso, potresti essere interessato a leggere come vengono allocate le proprietà 🔗 e come cambia 🔗. E perché no, anche riguardo alle ottimizzazioni delle VM come le inline caches 🔗, che sono particolarmente favorite dal monomorfismo 🔗. Buona lettura.