Start Debugging

Como adicionar rate limiting por endpoint no ASP.NET Core 11

Um guia completo de rate limiting por endpoint no ASP.NET Core 11: quando escolher fixed window vs sliding window vs token bucket vs concurrency, como RequireRateLimiting e [EnableRateLimiting] diferem, particionamento por usuário ou IP, o callback OnRejected, e a armadilha de implantação distribuída em que todo mundo cai.

Para limitar a taxa de um endpoint específico no ASP.NET Core 11, registre uma política nomeada em AddRateLimiter, chame app.UseRateLimiter() após o roteamento, e associe a política ao endpoint com RequireRateLimiting("name") em uma minimal API ou [EnableRateLimiting("name")] em uma action de MVC. O runtime traz quatro algoritmos integrados em Microsoft.AspNetCore.RateLimiting: fixed window, sliding window, token bucket e concurrency. O middleware retorna 429 Too Many Requests quando uma requisição é rejeitada e expõe um callback OnRejected para respostas personalizadas, incluindo Retry-After. Este guia cobre o .NET 11 preview 3 com C# 14, mas a API está estável desde o .NET 7 e cada exemplo de código compila sem alterações no .NET 8, 9 e 10.

Por que rate limiting “global” raramente é o que você quer

A configuração mais simples, um único limitador global que descarta requisições quando o processo inteiro está acima do orçamento, é atraente por uns dez segundos. Aí você percebe que o endpoint de login e a sonda estática de saúde compartilham esse orçamento. Uma botnet martelando /login vai derrubar /health com prazer, e seu balanceador de carga vai tirar a instância da rotação porque a sonda barata começou a retornar 429.

Rate limiting por endpoint resolve isso. Cada endpoint declara sua própria política com limites ajustados ao seu custo real: /login recebe um token bucket por IP apertado, /api/search recebe uma sliding window generosa, o endpoint de upload de arquivo recebe um limitador de concurrency, e /health não recebe nada. O limitador global, se você mantiver um, vira uma rede de segurança para abuso em nível de protocolo em vez da defesa principal.

O middleware Microsoft.AspNetCore.RateLimiting saiu de preview no .NET 7 e desde então só teve refinamentos de qualidade de vida. É parte first-class do framework no .NET 11, sem pacote NuGet adicional para instalar.

O Program.cs mínimo

Aqui está a menor configuração que adiciona duas políticas distintas por endpoint, aplica uma a um endpoint de minimal API e deixa o resto da aplicação sem throttling.

// .NET 11 preview 3, C# 14
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

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

    options.AddFixedWindowLimiter(policyName: "search", o =>
    {
        o.PermitLimit = 30;
        o.Window = TimeSpan.FromSeconds(10);
        o.QueueLimit = 0;
    });

    options.AddTokenBucketLimiter(policyName: "login", o =>
    {
        o.TokenLimit = 5;
        o.TokensPerPeriod = 5;
        o.ReplenishmentPeriod = TimeSpan.FromMinutes(1);
        o.QueueLimit = 0;
        o.AutoReplenishment = true;
    });
});

var app = builder.Build();

app.UseRateLimiter();

app.MapGet("/api/search", (string q) => Results.Ok(new { q }))
   .RequireRateLimiting("search");

app.MapPost("/api/login", (LoginRequest body) => Results.Ok())
   .RequireRateLimiting("login");

app.MapGet("/health", () => Results.Ok("ok"));

app.Run();

record LoginRequest(string Email, string Password);

Duas coisas para notar. Primeiro, RejectionStatusCode por padrão é 503 Service Unavailable, o que é errado para quase qualquer API pública. Defina como 429 uma vez, em AddRateLimiter, e esqueça. Segundo, app.UseRateLimiter() precisa vir depois de app.UseRouting() se você chamar o roteamento explicitamente, porque o middleware lê os metadados do endpoint para decidir qual política se aplica. O WebApplication integrado adiciona o roteamento automaticamente antes de middlewares terminais, então a chamada explícita a UseRouting só é necessária se você tiver outro middleware que precise ficar entre o roteamento e o rate limiting.

RequireRateLimiting vs [EnableRateLimiting]

O ASP.NET Core tem duas formas igualmente válidas de associar uma política a um endpoint, e elas existem porque minimal APIs e MVC têm histórias de metadados diferentes.

Para minimal APIs e grupos de endpoints, o método fluente RequireRateLimiting em IEndpointConventionBuilder é a chamada certa:

// .NET 11, C# 14
var api = app.MapGroup("/api/v1").RequireRateLimiting("search");

api.MapGet("/products", (...) => ...);          // inherits "search"
api.MapGet("/orders", (...) => ...);            // inherits "search"
api.MapPost("/login", (...) => ...)
   .RequireRateLimiting("login");               // overrides to "login"

Metadados em nível de endpoint vencem metadados em nível de grupo, então a sobrescrita em /login faz o que você esperaria: apenas a política mais específica do endpoint é aplicada.

Para controllers MVC, a forma com atributo é a chamada certa:

// .NET 11, C# 14
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("search")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IActionResult List() => Ok(/* ... */);

    [HttpGet("{id}")]
    [EnableRateLimiting("hot")]    // narrower policy for a hot endpoint
    public IActionResult Get(int id) => Ok(/* ... */);

    [HttpPost("import")]
    [DisableRateLimiting]          // bypass entirely for an internal endpoint
    public IActionResult Import() => Ok();
}

[EnableRateLimiting] e [DisableRateLimiting] seguem as regras padrão de resolução de atributos do ASP.NET Core: nível de action vence nível de controller, e DisableRateLimiting sempre vence. Misturar os estilos fluente e de atributo é tranquilo, o pipeline de metadados lê os dois da mesma forma.

Um erro comum é colocar [EnableRateLimiting] em um endpoint de minimal API com .WithMetadata(new EnableRateLimitingAttribute("search")). Funciona, mas RequireRateLimiting("search") é mais curto e mais claro.

Escolhendo um algoritmo

Os quatro algoritmos integrados respondem a quatro formatos diferentes de “com que frequência é demais”, e escolher errado se manifesta como picos de tráfego que furam seu limite ou usuários legítimos recebendo 429 durante rajadas normais.

Fixed window conta requisições em buckets de tempo não sobrepostos. PermitLimit = 100, Window = 1s significa até 100 requisições em cada segundo alinhado ao relógio. Barato de calcular e fácil de raciocinar, mas permite uma rajada de 200 requisições na fronteira da janela: 100 no último milissegundo de uma janela, 100 no primeiro milissegundo da próxima. Use para limites de custo onde a rajada é aceitável, ou para anti-abuso não crítico onde você não quer gastar CPU rastreando.

Sliding window divide a janela em segmentos e os rola para frente. PermitLimit = 100, Window = 1s, SegmentsPerWindow = 10 significa 100 requisições em qualquer fatia de 1 segundo, avaliada em incrementos de 100ms. Elimina a rajada na fronteira ao custo de mais contabilidade por requisição. Esse é o padrão sensato para endpoints públicos de leitura.

Token bucket repõe TokensPerPeriod tokens a cada ReplenishmentPeriod, até TokenLimit. Cada requisição consome um token. Rajadas são permitidas até TokenLimit, depois a taxa estabiliza na taxa de reposição. Esse é o modelo certo para qualquer endpoint onde você queira permitir uma pequena rajada (um usuário logado abre cinco abas) mas limitar a taxa sustentada (nada de scraping). Login, redefinição de senha e endpoints de envio de e-mail são todos candidatos a token bucket.

Concurrency limita o número de requisições em voo ao mesmo tempo, independentemente da duração. PermitLimit = 4 significa no máximo quatro requisições concorrentes; a quinta ou entra na fila ou é rejeitada. Use para endpoints que batem em um recurso lento downstream: uploads grandes de arquivos, geração de relatórios cara, ou qualquer endpoint onde o custo é tempo de relógio em um worker em vez de contagem de requisições.

As opções QueueLimit e QueueProcessingOrder são compartilhadas entre os quatro. QueueLimit = 0 significa “rejeitar imediatamente quando estiver no limite”, que é o que você quer para a maioria das APIs HTTP porque os clientes vão tentar de novo após 429 mesmo. Limites de fila não-zero fazem sentido para limitadores de concurrency onde o trabalho é curto e enfileirar por 200ms é mais barato que mandar o cliente para um loop de retry.

Particionamento: por usuário, por IP, por tenant

Um único bucket compartilhado por endpoint raramente é o que você quer. Se /api/search permite 30 requisições por 10 segundos globalmente, um cliente barulhento bloqueia todo mundo. Limitadores particionados dão a cada “chave” seu próprio bucket.

A sobrecarga fluente AddPolicy recebe um HttpContext e retorna um RateLimitPartition<TKey>:

// .NET 11, C# 14
options.AddPolicy("per-user-search", context =>
{
    var key = context.User.Identity?.IsAuthenticated == true
        ? context.User.FindFirst("sub")?.Value ?? "anon"
        : context.Connection.RemoteIpAddress?.ToString() ?? "unknown";

    return RateLimitPartition.GetSlidingWindowLimiter(key, _ => new SlidingWindowRateLimiterOptions
    {
        PermitLimit = 60,
        Window = TimeSpan.FromMinutes(1),
        SegmentsPerWindow = 6,
        QueueLimit = 0
    });
});

A factory é chamada uma vez por chave de partição. O runtime cacheia o limitador resultante em um PartitionedRateLimiter, então requisições subsequentes com a mesma chave reutilizam a mesma instância de limitador. O uso de memória escala com o número de chaves distintas que você acabar vendo, por isso você deveria evictar limitadores ociosos: o framework faz isso automaticamente quando um limitador fica ocioso por IdleTimeout (padrão de 1 minuto), mas você pode ajustar com as sobrecargas de RateLimitPartition.GetSlidingWindowLimiter(key, factory).

Duas pegadinhas de particionamento:

  1. RemoteIpAddress é null atrás de um reverse proxy a menos que você chame app.UseForwardedHeaders() com ForwardedHeaders.XForwardedFor configurado e uma lista KnownProxies ou KnownNetworks. Sem isso, toda requisição recebe a chave de partição "unknown" e você tem um limitador global de novo.
  2. Usuários autenticados e anônimos se misturam na mesma partição se você só usa sub como chave. Use um prefixo como "user:" ou "ip:" para que um atacante deslogado não possa colidir com o bucket de um usuário real.

Para políticas mais complexas (por tenant, por API key, vários limitadores encadeados), implemente IRateLimiterPolicy<TKey> e registre com options.AddPolicy<string, MyPolicy>("name"). A interface da política te dá o mesmo método GetPartition mais um callback OnRejected no escopo daquela política.

Personalizando a resposta de rejeição

A resposta 429 padrão é um corpo vazio sem header Retry-After. Isso está bom para APIs internas, mas clientes públicos (browsers, SDKs, integrações de terceiros) esperam uma dica. O callback OnRejected roda depois que o limitador rejeita mas antes que a resposta seja escrita:

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

    context.HttpContext.Response.ContentType = "application/problem+json";
    await context.HttpContext.Response.WriteAsJsonAsync(new
    {
        type = "https://tools.ietf.org/html/rfc6585#section-4",
        title = "Too Many Requests",
        status = 429,
        detail = "Rate limit exceeded. Retry after the indicated period."
    }, cancellationToken);
};

Dois detalhes fáceis de errar. Primeiro, MetadataName.RetryAfter só é populado por limitadores de token bucket e de reposição, não por fixed window ou sliding window. Limitadores de sliding window podem calcular um retry-after a partir de Window / SegmentsPerWindow, mas a conta é com você. Segundo, o callback OnRejected roda no caminho do middleware do rate limiter, não dentro do endpoint, então acessar serviços específicos do endpoint via context.HttpContext.RequestServices funciona mas acessar filtros de controller ou contexto de action não funciona, eles ainda não estão vinculados.

Se você quer um OnRejected por política em vez de um global, implemente IRateLimiterPolicy<TKey> e sobrescreva OnRejected na política. O callback em nível de política roda além do global, então cuidado para não escrever o corpo da resposta duas vezes.

A armadilha da implantação distribuída

Cada exemplo de código acima armazena o estado de rate limit em memória do processo. Isso está bom quando você roda uma única instância, e é catastrófico quando você escala horizontalmente. Três réplicas atrás de um balanceador de carga com PermitLimit = 100 por 10 segundos na verdade permitem 300 requisições por 10 segundos, porque cada réplica conta independentemente. Sticky sessions só ajudam se seu hash distribuir as chaves de partição uniformemente, o que tipicamente não acontece.

Não existe um rate limiter distribuído integrado em Microsoft.AspNetCore.RateLimiting. As opções mantidas no .NET 11 são:

Não implemente seu próprio limitador distribuído em cima de IDistributedCache e INCRBY a menos que tenha lido o post do blog da Cloudflare sobre contadores deslizantes distribuídos e tenha uma opinião forte sobre desvio de relógio.

Testando endpoints com rate limit

Testes de integração com WebApplicationFactory<TEntryPoint> funcionam, mas o rate limiter não é resetado entre testes por padrão. Duas estratégias:

  1. Sobrescrever a política no host de teste. Injete um limitador permissivo (PermitLimit = int.MaxValue) para o ambiente de teste, e escreva um conjunto separado de testes que batem no limitador explicitamente com uma política real.
  2. Desabilitar o limitador para o endpoint sob teste. Envolva suas chamadas MapGroup/RequireRateLimiting em if (!env.IsEnvironment("Testing")), ou use [DisableRateLimiting] em sobrescritas de teste.

O middleware também expõe RateLimiterOptions.GlobalLimiter para um limitador particionado de nível superior que roda em toda requisição antes das políticas por endpoint. É o lugar certo para um portão por IP do tipo “você é obviamente um bot”, e o lugar certo para adicionar um header Retry-After em toda rejeição independentemente de qual política nomeada disparou. Não use como substituto para políticas por endpoint; os dois compõem, não substituem um ao outro.

Quando o middleware integrado não é suficiente

O middleware cobre 90% dos casos. Os 10% restantes geralmente envolvem um destes:

Relacionado

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar