Condividi tramite


Limite di frequenza di un gestore HTTP in .NET

In questo articolo si apprenderà come creare un gestore HTTP lato client che limita il numero di richieste inviate. Verrà visualizzato un HttpClient che accede alla risorsa "www.example.com". Le risorse vengono utilizzate dalle app che si basano su di esse e quando un'app effettua troppe richieste per una singola risorsa, può causare conflitti di risorse. I confilitti di risorse si verificano quando una risorsa viene usata da troppe app e la risorsa non è in grado di gestire tutte le app che la richiedono. Ciò può comportare un'esperienza negativa per l'utente e, in alcuni casi, può addirittura causare un attacco Denial of Service (DoS). Per altre informazioni su DoS, vedere OWASP: Denial of Service.

Cos'è la limitazione della frequenza?

Il limite di frequenza è il concetto di limitazione della quantità di accesso a una risorsa. Ad esempio, si potrebbe essere a conoscenza del fatto che un database a cui l'app accede può gestire in modo sicuro 1.000 richieste al minuto, ma potrebbe non essere in grado di gestirne molte di più. È possibile inserire un limitatore di frequenza nell'app che consente solo 1.000 richieste ogni minuto e rifiuta qualsiasi altra richiesta prima di poter accedere al database. In questo modo, limita il database e permette all'applicazione di gestire un numero sicuro di richieste. Si tratta di un modello comune nei sistemi distribuiti, in cui possono essere presenti più istanze di un'app in esecuzione e si vuole assicurarsi che non tutti tentino di accedere al database contemporaneamente. Esistono vari algoritmi di limite di frequenza diversi per controllare il flusso delle richieste.

Per usare il limite di frequenza in .NET, si farà riferimento al pacchetto NuGet System.Threading.RateLimiting .

Implementare una sottoclasse DelegatingHandler

Per controllare il flusso delle richieste, implementare una sottoclasse personalizzata DelegatingHandler. Si tratta di un tipo di HttpMessageHandler che consente di intercettare e gestire le richieste prima che vengano inviate al server. È anche possibile intercettare e gestire le risposte prima che vengano restituite al chiamante. In questo esempio, si implementerà una sottoclasse personalizzata DelegatingHandler che limita il numero di richieste che possono essere inviate a una singola risorsa. Si consideri la classe personalizzata seguente ClientSideRateLimitedHandler:

internal sealed class ClientSideRateLimitedHandler(
    RateLimiter limiter)
    : DelegatingHandler(new HttpClientHandler()), IAsyncDisposable
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        using RateLimitLease lease = await limiter.AcquireAsync(
            permitCount: 1, cancellationToken);

        if (lease.IsAcquired)
        {
            return await base.SendAsync(request, cancellationToken);
        }

        var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
        if (lease.TryGetMetadata(
                MetadataName.RetryAfter, out TimeSpan retryAfter))
        {
            response.Headers.Add(
                "Retry-After",
                ((int)retryAfter.TotalSeconds).ToString(
                    NumberFormatInfo.InvariantInfo));
        }

        return response;
    }

    async ValueTask IAsyncDisposable.DisposeAsync()
    { 
        await limiter.DisposeAsync().ConfigureAwait(false);

        Dispose(disposing: false);
        GC.SuppressFinalize(this);
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);

        if (disposing)
        {
            limiter.Dispose();
        }
    }
}

Il codice C# precedente:

  • Eredita il tipo DelegatingHandler.
  • Implementa l'interfaccia IAsyncDisposable.
  • Definisce un campo RateLimiter assegnato dal costruttore.
  • Esegue l'override del metodo SendAsync per intercettare e gestire le richieste prima che vengano inviate al server.
  • Esegue l'override del metodo DisposeAsync() per eliminare l'istanza RateLimiter.

Osservando più attentamente il metodo SendAsync, si noterà che:

  • Si basa sull'istanza RateLimiter per acquisire un RateLimitLease da AcquireAsync.
  • Quando la proprietà lease.IsAcquired è true, la richiesta viene inviata al server.
  • In caso contrario, viene restituito HttpResponseMessage con un codice di stato 429 e, se lease contiene un valore RetryAfter, l'intestazione Retry-After viene impostata su tale valore.

Emulare numerose richieste simultanee

Per inserire questa sottoclasse personalizzata DelegatingHandler nel test, si creerà un'app console che emula varie richieste simultanee. Questa classe Program crea un oggetto HttpClient con la personalizzazione ClientSideRateLimitedHandler:

var options = new TokenBucketRateLimiterOptions
{ 
    TokenLimit = 8, 
    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
    QueueLimit = 3, 
    ReplenishmentPeriod = TimeSpan.FromMilliseconds(1), 
    TokensPerPeriod = 2, 
    AutoReplenishment = true
};

// Create an HTTP client with the client-side rate limited handler.
using HttpClient client = new(
    handler: new ClientSideRateLimitedHandler(
        limiter: new TokenBucketRateLimiter(options)));

// Create 100 urls with a unique query string.
var oneHundredUrls = Enumerable.Range(0, 100).Select(
    i => $"https://example.com?iteration={i:0#}");

// Flood the HTTP client with requests.
var floodOneThroughFortyNineTask = Parallel.ForEachAsync(
    source: oneHundredUrls.Take(0..49), 
    body: (url, cancellationToken) => GetAsync(client, url, cancellationToken));

var floodFiftyThroughOneHundredTask = Parallel.ForEachAsync(
    source: oneHundredUrls.Take(^50..),
    body: (url, cancellationToken) => GetAsync(client, url, cancellationToken));

await Task.WhenAll(
    floodOneThroughFortyNineTask,
    floodFiftyThroughOneHundredTask);

static async ValueTask GetAsync(
    HttpClient client, string url, CancellationToken cancellationToken)
{
    using var response =
        await client.GetAsync(url, cancellationToken);

    Console.WriteLine(
        $"URL: {url}, HTTP status code: {response.StatusCode} ({(int)response.StatusCode})");
}

Nell'app console precedente:

  • TokenBucketRateLimiterOptions vengono configurati con un limite di token di 8 e l'ordine di elaborazione della coda di OldestFirst, un limite di coda di 3 e il periodo di rifornimento di 1 millisecondi, un token per periodo di valore di 2 e un valore di rifornimento automatico pari a true.
  • Viene creato HttpClient con ClientSideRateLimitedHandler configurato con TokenBucketRateLimiter.
  • Per emulare 100 richieste, Enumerable.Range crea 100 URL, ognuno con un parametro di stringa di query univoco.
  • Due oggetti Task vengono assegnati dal metodo Parallel.ForEachAsync, suddividendo gli URL in due gruppi.
  • HttpClient viene usato per inviare una richiesta GET a ogni URL e la risposta viene scritta nella console.
  • Task.WhenAll attende il completamento di entrambe le attività.

Poiché HttpClient è configurato con ClientSideRateLimitedHandler, non tutte le richieste verranno inviate alla risorsa server. È possibile testare questa asserzione eseguendo l'app console. Si noterà che solo una frazione del numero totale di richieste viene inviata al server, mentre il resto viene rifiutato con un codice di stato HTTP di 429. Provare a modificare l'oggetto options utilizzato per creare TokenBucketRateLimiter per vedere come cambia il numero di richieste inviate al server.

Si consideri l'output di esempio seguente:

URL: https://example.com?iteration=06, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=60, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=55, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=59, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=57, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=11, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=63, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=13, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=62, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=65, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=64, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=67, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=14, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=68, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=16, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=69, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=70, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=71, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=17, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=18, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=72, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=73, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=74, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=19, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=75, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=76, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=79, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=77, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=21, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=78, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=81, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=22, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=80, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=20, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=82, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=83, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=23, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=84, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=24, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=85, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=86, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=25, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=87, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=26, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=88, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=89, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=27, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=90, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=28, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=91, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=94, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=29, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=93, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=96, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=92, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=95, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=31, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=30, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=97, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=98, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=99, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=32, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=33, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=34, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=35, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=36, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=37, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=38, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=39, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=40, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=41, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=42, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=43, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=44, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=45, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=46, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=47, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=48, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=15, HTTP status code: OK (200)
URL: https://example.com?iteration=04, HTTP status code: OK (200)
URL: https://example.com?iteration=54, HTTP status code: OK (200)
URL: https://example.com?iteration=08, HTTP status code: OK (200)
URL: https://example.com?iteration=00, HTTP status code: OK (200)
URL: https://example.com?iteration=51, HTTP status code: OK (200)
URL: https://example.com?iteration=10, HTTP status code: OK (200)
URL: https://example.com?iteration=66, HTTP status code: OK (200)
URL: https://example.com?iteration=56, HTTP status code: OK (200)
URL: https://example.com?iteration=52, HTTP status code: OK (200)
URL: https://example.com?iteration=12, HTTP status code: OK (200)
URL: https://example.com?iteration=53, HTTP status code: OK (200)
URL: https://example.com?iteration=07, HTTP status code: OK (200)
URL: https://example.com?iteration=02, HTTP status code: OK (200)
URL: https://example.com?iteration=01, HTTP status code: OK (200)
URL: https://example.com?iteration=61, HTTP status code: OK (200)
URL: https://example.com?iteration=05, HTTP status code: OK (200)
URL: https://example.com?iteration=09, HTTP status code: OK (200)
URL: https://example.com?iteration=03, HTTP status code: OK (200)
URL: https://example.com?iteration=58, HTTP status code: OK (200)
URL: https://example.com?iteration=50, HTTP status code: OK (200)

Si noterà che le prime voci registrate sono sempre le 429 risposte immediatamente restituite e le ultime voci sono sempre le 200 risposte. Ciò è dovuto al fatto che viene rilevato il limite di frequenza sul lato client ed evita di effettuare una chiamata HTTP a un server. Questo è un aspetto positivo, perché significa che il server non è inondato di richieste. Significa anche che il limite di frequenza viene applicato in modo coerente in tutti i client.

Si noti anche che la stringa di query di ogni URL è univoca: esaminare il parametro iteration per vedere che viene incrementato di uno per ogni richiesta. Questo parametro consente di illustrare che le 429 risposte non provengono dalle prime richieste, ma piuttosto dalle richieste effettuate dopo il raggiungimento del limite di frequenza. Le 200 risposte arrivano in un secondo momento, ma queste richieste sono state effettuate in precedenza, prima del raggiungimento del limite.

Per comprendere meglio i vari algoritmi di limite di frequenza, provare a riscrivere questo codice per accettare un'implementazione diversa RateLimiter. Oltre a TokenBucketRateLimiter, si potrebbe provare quanto segue:

  • ConcurrencyLimiter
  • FixedWindowRateLimiter
  • PartitionedRateLimiter
  • SlidingWindowRateLimiter

Riepilogo

In questo articolo, si è appreso come implementare una personalizzazione di ClientSideRateLimitedHandler. Questo modello può essere usato per implementare un client HTTP con frequenza limitata per le risorse che è noto dispongano di limiti API. In questo modo, si impedisce all'app client di effettuare richieste non necessarie al server e si evita anche che l'app venga bloccata dal server. Inoltre, con l'uso dei metadati per archiviare i valori di intervallo dei tentativi, è anche possibile implementare la logica di ripetizione automatica dei tentativi.

Vedi anche