Rate limiting em APIs com ASP.NET Core 10 e minimal APIs

Rate limiting é a técnica que impede uma API de receber mais requisições do que consegue atender. Toda API exposta na internet eventualmente enfrenta esse excesso, e a origem nem sempre é maliciosa.

Um script de integração com a condição de parada quebrada, um aplicativo móvel que reenvia tudo ao reconectar ou um pico legítimo produzem o mesmo efeito. O banco desacelera, as conexões se acumulam e a aplicação, embora no ar, deixa de ser utilizável.

Neste guia, você vai conhecer o middleware de rate limiting do ASP.NET Core 10, a versão LTS da plataforma, com foco em minimal APIs. O objetivo é ir dos fundamentos até as decisões que separam uma demonstração de uma configuração pronta para produção.

O princípio por trás do rate limiting

Um servidor possui recursos finitos, como memória, CPU, conexões com o banco e largura de banda. Quando a demanda ultrapassa essa capacidade, a degradação não acontece de forma gradual.

As requisições excedentes não desaparecem, porque ficam aguardando e ocupando memória e conexões. Então, o tempo de resposta cresce, os clientes atingem seus próprios timeouts e repetem as chamadas, o que amplia a carga.

O rate limiting interrompe esse ciclo com uma premissa simples: recusar rapidamente é melhor do que aceitar lentamente. Afinal, uma rejeição imediata custa quase nada e preserva o atendimento das requisições dentro da capacidade planejada.

A documentação oficial da Microsoft lista seis motivos para adotar a prática. São eles: prevenir abuso, garantir uso justo, proteger recursos, reforçar a segurança, manter a performance e controlar custos.

O último motivo pesa bastante em arquiteturas modernas. Nelas, uma única requisição pode acionar serviços cobrados por uso, como modelos de linguagem ou APIs de terceiros.

O que o rate limiting não resolve

A mesma documentação afirma que o rate limiting reduz o risco de negação de serviço, mas não protege completamente contra ataques distribuídos. Em um ataque volumétrico, cada requisição ainda chega à aplicação e consome recursos antes da rejeição.

Por isso, a proteção contra DDoS deve ocorrer na borda da rede. Serviços como Azure Web Application Firewall, Azure Front Door e Cloudflare cumprem esse papel.

Da mesma forma, o rate limiting não substitui autenticação, autorização, validação de entrada nem otimização de consultas lentas. Para uma visão mais ampla, vale ler sobre boas práticas de segurança em APIs ASP.NET Core.

Sua função é mais específica. Ele garante que nenhum cliente individual consuma mais do que a fração de capacidade atribuída a ele.

Os componentes do modelo

Antes do código, convém dominar o vocabulário do framework, pois ele aparece nos nomes das classes e na documentação.

  • Permit (permissão): a unidade controlada pelo limitador; normalmente cada requisição consome uma.
  • Lease (concessão): o objeto devolvido ao pedido de permissão, cuja propriedade IsAcquired indica se o pedido foi atendido.
  • Metadados: a concessão pode carregar o tempo estimado até a próxima permissão, exposto por MetadataName.RetryAfter.
  • Janela: o intervalo em que a contagem vale; a forma como ela avança diferencia os algoritmos.
  • Partição: a chave que separa as contagens entre clientes e responde de quem é o limite.
  • Fila: controlada por QueueLimit, define se o excedente aguarda ou é rejeitado na hora.

Configuração mínima de rate limiting em uma minimal API

O middleware de rate limiting faz parte do framework compartilhado do ASP.NET Core desde o .NET 7. Portanto, ele não exige nenhum pacote adicional do NuGet.

Os tipos, no entanto, ficam em dois namespaces, o que costuma confundir na primeira implementação. O System.Threading.RateLimiting traz a biblioteca base, independente de HTTP, enquanto o Microsoft.AspNetCore.RateLimiting traz a integração com o pipeline web.

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

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(options =>
{
    // O padrão do middleware é 503; troque para 429
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.AddFixedWindowLimiter("basic", limiter =>
    {
        limiter.PermitLimit = 5;
        limiter.Window = TimeSpan.FromSeconds(10);
        limiter.QueueLimit = 0;
    });
});

var app = builder.Build();

app.UseRateLimiter();

app.MapGet("/quotes/{currency}", (string currency) => Results.Ok(new { currency }))
   .RequireRateLimiting("basic");

app.Run();

Três pontos desse trecho merecem atenção. O primeiro é o RejectionStatusCode, cujo valor padrão é 503 e comunica indisponibilidade em vez de excesso.

O status correto é 429, que informa ao cliente que o problema é volume e que uma nova tentativa pode funcionar. Além disso, o 503 polui os painéis de monitoramento com falsos indicadores de falha.

O segundo ponto é a chamada a UseRateLimiter. Sem ela, as políticas ficam registradas, mas nunca são aplicadas, e nenhum erro aparece.

O terceiro é o RequireRateLimiting, que associa a política ao endpoint e também funciona em um MapGroup inteiro. Para controllers e Razor Pages, os equivalentes são os atributos [EnableRateLimiting] e [DisableRateLimiting].

Se você ainda está começando com esse modelo de endpoints, o artigo sobre minimal APIs no ASP.NET cobre a base.

Os quatro algoritmos de rate limiting

O rate limiting do framework oferece quatro algoritmos, e a escolha depende da natureza do problema. Três deles limitam a quantidade de requisições em um período, enquanto o quarto limita apenas quantas estão em execução ao mesmo tempo.

A documentação recomenda considerar o custo do endpoint na escolha. Esse custo inclui tempo de processamento, acesso a dados, CPU e operações de entrada e saída.

Janela fixa

A janela fixa divide o tempo em blocos iguais, cada um com um contador zerado ao fim do bloco. Ela tem o menor custo computacional e é a mais simples de explicar.

Sua limitação é o efeito de borda. Um cliente pode consumir o limite inteiro no fim de uma janela e de novo no início da seguinte, o que dobra o pico instantâneo permitido.

Janela deslizante

A janela deslizante divide cada janela em segmentos, definidos por SegmentsPerWindow. Assim, ela devolve aos poucos as permissões consumidas pelo segmento mais antigo.

options.AddSlidingWindowLimiter("query", limiter =>
{
    limiter.PermitLimit = 60;
    limiter.Window = TimeSpan.FromMinutes(1);
    limiter.SegmentsPerWindow = 6;
    limiter.QueueLimit = 0;
});

Esse comportamento suaviza o efeito de borda e entrega um limite muito mais fiel ao valor anunciado. Contudo, cada segmento mantém um contador por partição, então mais segmentos significam mais memória.

Para endpoints públicos de consulta, com volume alto e limite próximo do teto, essa costuma ser a escolha mais equilibrada.

Balde de tokens

O balde de tokens mantém uma reserva com capacidade máxima definida por TokenLimit. Ele repõe TokensPerPeriod tokens a cada ReplenishmentPeriod.

options.AddTokenBucketLimiter("partners", limiter =>
{
    limiter.TokenLimit = 100;
    limiter.TokensPerPeriod = 10;
    limiter.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
    limiter.AutoReplenishment = true;
    limiter.QueueLimit = 0;
});

A vantagem é separar duas grandezas que os outros algoritmos misturam: a rajada tolerada e a taxa média sustentada. Por exemplo, um cliente ocioso encontra a reserva cheia, enquanto um cliente em uso contínuo fica preso à taxa de reposição.

Esse modelo atende bem integrações em lote e aplicativos móveis que sincronizam ao abrir. Além disso, os auxiliares AddTokenBucketLimiter e RateLimitPartition.GetTokenBucketLimiter usam um único temporizador compartilhado para todos os limitadores que criam.

Já um TokenBucketRateLimiter construído manualmente com AutoReplenishment ganha um temporizador próprio por instância. Com muitas partições, isso eleva o custo.

Limite de concorrência

O limitador de concorrência não tem janela de tempo. Ele controla apenas quantas requisições estão em processamento no mesmo instante, devolvendo a permissão quando a resposta termina.

options.AddConcurrencyLimiter("report", limiter =>
{
    limiter.PermitLimit = 2;
    limiter.QueueLimit = 0;
});

É o algoritmo certo para endpoints caros, como relatórios, exportações e chamadas a modelos de linguagem. Como não dá para prever quando uma execução termina, ele não fornece o metadado RetryAfter.

Critérios de escolha do algoritmo de rate limiting

Algumas perguntas objetivas conduzem a escolha do algoritmo de rate limiting.

  • Se o gargalo é a execução simultânea, e não o volume acumulado, use o limite de concorrência.
  • Quando o problema é quantidade e há rajadas legítimas a tolerar, prefira o balde de tokens.
  • Sem necessidade de rajadas e com limite próximo da capacidade real, a janela deslizante evita o pico dobrado.
  • Nos demais casos, como tentativas de login, a janela fixa basta e é mais econômica.

Particionamento no rate limiting

O particionamento é a decisão mais importante de toda a configuração. Isso porque as políticas criadas por AddFixedWindowLimiter e seus equivalentes usam uma partição única, compartilhada por todos os clientes.

Nesse modelo de rate limiting, um único cliente com comportamento anômalo esgota o limite e derruba o atendimento de todos. Ou seja, o resultado é o contrário do objetivo da proteção.

O particionamento resolve isso com um contador independente por chave, como IP, usuário autenticado ou chave de API. Para isso, você usa AddPolicy, que recebe uma função executada a cada requisição.

options.AddPolicy("query", httpContext =>
{
    var userName = httpContext.User.Identity?.IsAuthenticated == true
        ? httpContext.User.Identity.Name
        : null;

    if (!string.IsNullOrWhiteSpace(userName))
    {
        // Usuários autenticados recebem uma cota maior
        return RateLimitPartition.GetSlidingWindowLimiter(
            partitionKey: $"user:{userName}",
            factory: _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = 300,
                Window = TimeSpan.FromMinutes(1),
                SegmentsPerWindow = 6
            });
    }

    var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";

    return RateLimitPartition.GetSlidingWindowLimiter(
        partitionKey: $"anonymous:{ip}",
        factory: _ => new SlidingWindowRateLimiterOptions
        {
            PermitLimit = 30,
            Window = TimeSpan.FromMinutes(1),
            SegmentsPerWindow = 6
        });
});

A função factory roda só na primeira ocorrência de cada chave, e as requisições seguintes reutilizam o limitador criado. Já os prefixos user: e anonymous: evitam colisões entre chaves de naturezas diferentes.

A diferença entre os limites é intencional. Afinal, um usuário autenticado responde pelo próprio consumo e merece uma cota mais generosa.

O risco das chaves controladas pelo cliente

A documentação alerta que partições criadas a partir de dados do usuário deixam a aplicação vulnerável a negação de serviço. Se a chave for um cabeçalho arbitrário, o atacante envia um valor novo a cada requisição e sempre recebe um limitador cheio.

Além de anular a proteção, cada chave distinta cria um limitador em memória, o que pode esgotar o processo. O alerta vale também para o IP, sujeito a falsificação de origem, conforme a BCP 38.

Portanto, particione somente por valores validados, como a identidade autenticada ou uma chave de API já confirmada.

Limites diferenciados por plano

Em plataformas multi tenant, a mesma função de partição do rate limiting pode consultar o plano contratado e atribuir limites distintos.

var plan = httpContext.User.FindFirst("plan")?.Value ?? "free";

var limit = plan switch
{
    "enterprise" => 10_000,
    "pro" => 1_000,
    _ => 100
};

Como a fábrica executa uma vez por chave, uma mudança de plano não aparece enquanto a partição ficar em memória. Então, inclua o plano na própria chave, como em tenant:{id}:{plan}, para forçar uma partição nova.

O cenário multi tenant tem um artigo dedicado no blog, sobre rate limiting multi tenant no .NET 10 e C# 14.

Limitador global e exclusões

No rate limiting, a propriedade GlobalLimiter define um limitador aplicado a todas as requisições que passam pelo middleware.

options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
    RateLimitPartition.GetFixedWindowLimiter(
        partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        factory: _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 600,
            Window = TimeSpan.FromMinutes(1)
        }));

Ele funciona como rede de segurança para endpoints novos que ficaram sem política nomeada. Nesse caso, a requisição precisa obter permissão tanto do limitador global quanto da política do endpoint.

Endpoints de infraestrutura, como verificações de saúde, devem ficar de fora com DisableRateLimiting.

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

Se o orquestrador receber 429 em um health check, ele pode reiniciar uma instância saudável. O artigo sobre health check no ASP.NET mostra como montar esse endpoint.

A resposta de rejeição do rate limiting

Um 429 sem corpo e sem tempo de espera deixa o cliente sem alternativa senão repetir a chamada imediatamente. Por isso, o callback OnRejected permite compor uma resposta completa, com o cabeçalho Retry-After e um corpo ProblemDetails.

using System.Globalization;
using Microsoft.AspNetCore.Mvc;

options.OnRejected = async (context, cancellationToken) =>
{
    var response = context.HttpContext.Response;
    response.StatusCode = StatusCodes.Status429TooManyRequests;

    var seconds = 0;

    if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
    {
        seconds = (int)retryAfter.TotalSeconds;
        response.Headers.RetryAfter = seconds.ToString(NumberFormatInfo.InvariantInfo);
    }

    var problem = new ProblemDetails
    {
        Status = StatusCodes.Status429TooManyRequests,
        Title = "Too many requests",
        Detail = seconds > 0
            ? $"Try again in {seconds} seconds."
            : "Try again in a moment.",
        Instance = context.HttpContext.Request.Path
    };

    response.ContentType = "application/problem+json";
    await response.WriteAsJsonAsync(problem, cancellationToken);
};

O status precisa vir antes da escrita do corpo, porque a gravação dispara o envio dos cabeçalhos. Além disso, NumberFormatInfo.InvariantInfo garante um cabeçalho neutro, seja qual for a cultura do servidor.

Esse callback também é o lugar certo para registrar a rejeição em log, de preferência como aviso. Contudo, controle o volume, já que um cliente em loop pode gerar milhares de linhas por segundo.

Evite ainda que a mensagem revele dados do cliente bloqueado. Por exemplo, dizer que determinado email excedeu tentativas confirma que ele existe na base.

Para entender o formato do corpo, veja o artigo sobre ProblemDetails e a RFC 9457 no .NET 10.

Ordem do middleware de rate limiting no pipeline

A posição de UseRateLimiter influencia diretamente as partições. Segundo a documentação, com APIs específicas por endpoint o middleware deve vir depois de UseRouting, requisito que as minimal APIs atendem por padrão.

Quando a partição depende do usuário autenticado, o middleware também precisa vir depois de UseAuthentication. Caso contrário, HttpContext.User estará vazio.

app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

Inverter essa ordem não gera erro de compilação nem de execução. Simplesmente, todas as requisições caem na partição anônima, uma falha silenciosa que costuma aparecer só em produção.

Rate limiting em produção

Algumas decisões só aparecem quando a API roda atrás de proxies, em várias instâncias e sob monitoramento real.

Proxies reversos e o endereço IP real

Atrás de um balanceador ou proxy reverso, RemoteIpAddress retorna o endereço do proxy. Assim, todos os clientes passam a dividir a mesma partição.

A correção exige o middleware de cabeçalhos encaminhados, configurado com os proxies confiáveis.

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.0.0.0/8"));
});

No ASP.NET Core 10, KnownNetworks e o tipo Microsoft.AspNetCore.HttpOverrides.IPNetwork ficaram obsoletos e geram o aviso ASPDEPR005. A substituição é KnownIPNetworks, baseada em System.Net.IPNetwork.

Limpar as listas de proxies conhecidos para aceitar qualquer origem reabre a falsificação do X-Forwarded-For. Por outro lado, serviços de borda oferecem cabeçalhos mais confiáveis, como CF-Connecting-IP no Cloudflare e X-Azure-ClientIP no Azure Front Door.

Múltiplas instâncias

O estado do rate limiting fica na memória do processo, então cada instância mantém contagens próprias. Com três instâncias, um limite de cem requisições por minuto pode admitir até trezentas.

As alternativas são dividir o limite pelo número de instâncias, delegar o controle a um gateway ou usar um armazenamento compartilhado como Redis. No entanto, o framework não oferece limitador distribuído oficial, e o Redis acrescenta latência e uma dependência crítica.

Para limites de proteção de capacidade, a imprecisão costuma ser aceitável. Já para limites ligados a cobrança, a exatidão tende a justificar o custo.

Observabilidade do rate limiting

O middleware publica métricas nativas sob o medidor Microsoft.AspNetCore.RateLimiting, sem instrumentação extra. Entre elas está aspnetcore.rate_limiting.requests, que conta as tentativas de obter concessão com o resultado.

Há também aspnetcore.rate_limiting.active_request_leases, para concessões ativas, além de instrumentos para fila, tempo em fila e duração da concessão. Todos carregam o nome da política como atributo.

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics.AddMeter("Microsoft.AspNetCore.RateLimiting"));

Uma taxa de rejeição persistentemente alta indica limite subdimensionado. Por outro lado, rejeições concentradas em uma partição apontam para um cliente anômalo.

Configuração externa e testes

Os valores de rate limiting devem vir da configuração da aplicação. Dessa forma, você ajusta cada ambiente sem recompilar nem reimplantar.

A documentação também recomenda testes de carga antes da implantação, com ferramentas como Apache JMeter e Azure Load Testing. Além disso, um teste de integração com WebApplicationFactory que comprove o 429 impede que uma refatoração remova a proteção sem aviso.

Conclusão

O middleware de rate limiting do ASP.NET Core 10 oferece, sem dependências externas, os quatro algoritmos que a maioria dos cenários exige. A configuração é simples, e o esforço é pequeno diante do ganho em resiliência.

As decisões que definem a eficácia, porém, não estão na sintaxe. Elas estão na escolha do algoritmo, na chave de partição e na adaptação ao ambiente de produção.

Um limitador sem particionamento, atrás de um proxy sem cabeçalhos encaminhados ou com chaves controladas pelo cliente transmite falsa segurança. Em resumo, a pergunta que orienta todo o rate limiting continua sendo de quem é o limite.

FAQ

O rate limiting do ASP.NET Core exige algum pacote do NuGet?

Não, o middleware faz parte do framework compartilhado desde o .NET 7. No .NET 10, ele continua disponível sem dependências adicionais.

Por que minha API retorna 503 em vez de 429?

O valor padrão de RejectionStatusCode é 503. Configure explicitamente StatusCodes.Status429TooManyRequests.

Registrei a política, mas nada é bloqueado. O que pode estar errado?

Normalmente falta a chamada a app.UseRateLimiter() ou a associação da política ao endpoint com RequireRateLimiting.

Qual algoritmo devo usar para um endpoint de login?

A janela fixa com limite baixo, particionada por IP, costuma bastar. Em volumes tão pequenos, o efeito de borda é irrelevante.

Posso usar fila para não rejeitar requisições?

É possível, mas em APIs HTTP a fila tende a segurar requisições cujos clientes já desistiram. Na maioria dos casos, QueueLimit igual a zero é a escolha mais segura.

O limite funciona com várias instâncias da aplicação?

Não de forma exata, pois o estado fica em memória por instância. Ajuste os valores, delegue ao gateway ou adote um armazenamento compartilhado.

Por que o Retry-After não aparece no limitador de concorrência?

Porque esse limitador não consegue estimar quando uma requisição em andamento termina. Assim, ele não fornece o metadado RetryAfter.

É seguro particionar pelo endereço IP do cliente?

É aceitável quando não há identidade, desde que os cabeçalhos encaminhados estejam corretos. Ainda assim, o IP pode ser falsificado, o que limita a confiabilidade da chave.