← tutti gli articoli

.NET

Implementare il rate limiting nelle API ASP.NET Core

Scopri come proteggere le API ASP.NET Core con il middleware di rate limiting integrato, policy con nome, partizioni per client, response HTTP 429 e scelte di configurazione pratiche.

Pipeline delle request di un'API ASP.NET Core con middleware di rate limiting che rifiuta le request in eccesso con HTTP 429

Il rate limiting controlla quante request accetta un'API durante un periodo specifico. È utile per le API pubbliche, gli endpoint di autenticazione, le operazioni costose e le integrazioni in cui un singolo client non dovrebbe consumare tutte le risorse disponibili.

ASP.NET Core fornisce un middleware di rate limiting integrato con algoritmi fixed window, sliding window, token bucket e concurrency. Le policy possono essere globali oppure assegnate solo a endpoint selezionati. (learn.microsoft.com)

Perché il rate limiting è importante

Un rate limiter può aiutare un'API a:

  • impedire che un singolo client monopolizzi le risorse;
  • ridurre i picchi di traffico accidentali;
  • proteggere la capacità di database, CPU e servizi esterni;
  • applicare limiti diversi a utenti o piani differenti;
  • restituire una response prevedibile quando la capacità non è disponibile.

Il rate limiting non costituisce una protezione DDoS completa. Gli attacchi distribuiti su larga scala dovrebbero essere gestiti anche ai confini dell'infrastruttura, ad esempio tramite una CDN, un web application firewall, un API gateway o un servizio di protezione cloud. (learn.microsoft.com)

Configurare una policy fixed-window

L'algoritmo fixed-window consente un numero definito di request durante una finestra temporale. Al termine della finestra, il contatore viene azzerato.

La seguente configurazione di Program.cs accetta 20 request al minuto e ne mette in coda fino a due aggiuntive:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.AddFixedWindowLimiter("api", limiterOptions =>
    {
        limiterOptions.PermitLimit = 20;
        limiterOptions.Window = TimeSpan.FromMinutes(1);
        limiterOptions.QueueLimit = 2;
        limiterOptions.QueueProcessingOrder =
            QueueProcessingOrder.OldestFirst;
    });
});

var app = builder.Build();

app.UseRouting();
app.UseRateLimiter();

app.MapGet("/products", () => Results.Ok(new[]
{
    new { Id = 1, Name = "Keyboard" },
    new { Id = 2, Name = "Mouse" }
})).RequireRateLimiting("api");

app.Run();

PermitLimit definisce quante request vengono accettate, mentre Window stabilisce quando vengono ripristinati i permessi. QueueLimit controlla quante request possono attendere un permesso. Impostandolo su zero, le request in eccesso vengono rifiutate immediatamente.

Per le policy specifiche per endpoint, UseRateLimiter dovrebbe essere eseguito dopo UseRouting. (learn.microsoft.com)

Restituire una response 429 utile

Per impostazione predefinita, le request rifiutate possono ricevere il codice di stato HTTP 429 Too Many Requests. Un callback OnRejected consente all'API di restituire una response più chiara e, quando disponibile, un header Retry-After.

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.OnRejected = async (context, cancellationToken) =>
    {
        if (context.Lease.TryGetMetadata(
            MetadataName.RetryAfter,
            out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int)retryAfter.TotalSeconds).ToString();
        }

        await context.HttpContext.Response.WriteAsJsonAsync(new
        {
            error = "Too many requests. Please try again later."
        }, cancellationToken);
    };

    options.AddFixedWindowLimiter("api", limiterOptions =>
    {
        limiterOptions.PermitLimit = 20;
        limiterOptions.Window = TimeSpan.FromMinutes(1);
        limiterOptions.QueueLimit = 0;
    });
});

Il valore Retry-After aiuta i client a decidere quando è probabile che un'altra request vada a buon fine. È comunque importante che i client gestiscano le response 429, anziché presumere che ogni request venga accettata.

Limitare separatamente ogni client

Una singola policy condivisa significa che ogni chiamante consuma la stessa quota di request. Il partizionamento crea un limiter separato per ciascun utente, API key, tenant o indirizzo IP.

options.AddPolicy("per-client", httpContext =>
{
    var clientId =
        httpContext.User.Identity?.Name ??
        httpContext.Connection.RemoteIpAddress?.ToString() ??
        "anonymous";

    return RateLimitPartition.GetFixedWindowLimiter(
        partitionKey: clientId,
        factory: _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 10,
            Window = TimeSpan.FromMinutes(1),
            QueueLimit = 0,
            AutoReplenishment = true
        });
});

Applicala come qualsiasi altra policy con nome:

app.MapPost("/orders", CreateOrder)
   .RequireRateLimiting("per-client");

Un ID utente autenticato o un'API key controllata è generalmente una partition key più affidabile di un indirizzo IP. Le applicazioni dietro proxy devono inoltre configurare correttamente i forwarded header prima di fare affidamento sull'IP del client.

Scegliere un algoritmo

Usa fixed window quando è sufficiente una quota semplice. Usa sliding window per limiti più uniformi tra i confini delle finestre temporali. Usa token bucket quando sono accettabili picchi controllati. Usa concurrency limiting quando il problema principale è quante operazioni costose vengono eseguite simultaneamente, anziché quante request arrivano al minuto. (learn.microsoft.com)

Prima del deploy in produzione, mantieni i limiti nella configurazione, monitora le request rifiutate ed esegui load test sui valori selezionati. Un limite dovrebbe riflettere il costo e la capacità effettivi dell'endpoint protetto, non un numero arbitrario copiato in tutta l'API.

Riferimenti