Rate limiting no ASP.NET: os 4 algoritmos na prática

O rate limiting no ASP.NET é o mecanismo que define quanto trabalho cada cliente pode pedir à sua API em um intervalo de tempo. Neste artigo, vamos entender os quatro algoritmos nativos do framework, quando usar cada um e como configurar partições, respostas 429 e o cabeçalho Retry-After no .NET 10.
O que é rate limiting no ASP.NET
Basta um cliente mal comportado para derrubar uma API inteira. Na maioria das vezes, ele nem é um atacante.
Pense em um aplicativo móvel com um laço de retentativas defeituoso. Pense também em uma integração de parceiro que consulta o mesmo endpoint a cada 100 milissegundos.
Cada uma dessas requisições consome banco de dados, cache e threads exatamente como uma requisição legítima. Por isso, precisamos de uma regra que diga quanto cada chamador pode fazer em determinado período.
Na prática, o rate limiting no ASP.NET cumpre três papéis principais:
- Proteção contra abuso: tentativas de força bruta no login, scraping e inundações de requisições param antes de chegar aos handlers.
- Proteção contra acidentes: um laço de retentativas quebrado deixa de derrubar o banco para todos os usuários.
- Divisão justa: o plano gratuito recebe uma cota pequena, o plano pago recebe uma cota maior e nenhum cliente barulhento monopoliza o servidor.
Desde o .NET 7, esse recurso faz parte do próprio framework. Os algoritmos ficam em System.Threading.RateLimiting e o middleware fica em Microsoft.AspNetCore.RateLimiting, então não precisamos instalar nenhum pacote NuGet.
Como configurar o rate limiting no ASP.NET
A configuração do rate limiting no ASP.NET segue três passos. Primeiro registramos as políticas, em seguida adicionamos o middleware e, por fim, associamos uma política a cada endpoint.
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// As políticas nomeadas entram aqui
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapPost("/api/auth/login", LoginEndpoint.Handle)
.RequireRateLimiting("login");
app.Run();
O método AddRateLimiter registra as opções que guardam todas as políticas nomeadas. Já o UseRateLimiter adiciona o middleware que confere cada requisição contra a política do endpoint.
O RequireRateLimiting("login") associa a política pelo nome. Endpoints sem política simplesmente não têm limite algum.
A ordem dos middlewares também importa. Quando o UseRateLimiter vem depois do UseAuthentication, as claims do usuário já estão disponíveis, o que permite limites por usuário mais adiante.
Além disso, a documentação oficial de rate limiting do ASP.NET Core lembra que o middleware precisa vir depois do UseRouting quando usamos APIs específicas de endpoint, como o atributo [EnableRateLimiting]. Com apenas limitadores globais, ele pode vir antes.
Se você está começando agora com esse modelo de endpoints, vale revisar como funciona a validação em Minimal APIs no .NET 10, porque os dois recursos convivem no mesmo pipeline.
Fixed window: a janela fixa
Imagine uma padaria que vende 100 pães por hora. Às 9h, o contador volta para 100 e, quando sai o centésimo pão, a porta fecha até as 10h.
No rate limiting no ASP.NET, esse é o limitador de janela fixa: um contador e um horário de reinício.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("login", limiter =>
{
limiter.PermitLimit = 5;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
});
A propriedade PermitLimit define quantas requisições a janela aceita. Enquanto isso, Window define quanto tempo falta para o contador reiniciar.
O QueueLimit controla o que acontece quando a cota acaba. Com 0, o limitador rejeita a requisição imediatamente, e com 10, até dez requisições aguardam a próxima janela.
Para login, rejeitar na hora é a melhor escolha. Afinal, enfileirar uma tentativa de força bruta apenas mantém uma conexão aberta para o atacante.
O ponto fraco dessa abordagem está na fronteira da janela. Um cliente pode enviar 5 requisições às 10:00:59 e outras 5 às 10:01:00, ou seja, 10 chamadas em dois segundos contra um limite de 5 por minuto.
Para login, esse excesso não muda nada. Contudo, para um serviço que suporta exatamente 100 chamadas por minuto, receber 200 em dois segundos é um problema real.
Existe ainda um detalhe que confunde muita gente. O helper AddFixedWindowLimiter cria um único contador compartilhado por todos os chamadores, então cinco logins errados de qualquer pessoa bloqueiam o endpoint para o mundo inteiro.
Use a janela fixa nestes cenários:
- Login, redefinição de senha e envio de e-mails.
- Limites simples do tipo "N tentativas e depois aguarde".
- Cenários com pouca memória disponível, porque ela guarda apenas um contador e um horário por partição.
Evite a janela fixa quando o pico na fronteira prejudica um serviço externo ou quando você precisa de um tráfego mais uniforme.
Sliding window: a janela deslizante
Voltemos à padaria, agora com outra forma de contabilidade. Em vez de reiniciar na virada da hora, o padeiro anota cada venda e atende um novo cliente apenas se vendeu menos de 100 pães nos últimos 60 minutos.
Nesse modelo, não existe um momento mágico em que tudo reinicia. Assim, o rate limiting no ASP.NET implementa essa ideia com segmentos.
A janela se divide em fatias iguais, e cada fatia conta suas próprias requisições. Quando a fatia mais antiga sai da janela, as requisições dela voltam para a cota.
options.AddSlidingWindowLimiter("public-api", limiter =>
{
limiter.PermitLimit = 100;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.SegmentsPerWindow = 6;
limiter.QueueLimit = 0;
});
app.MapGet("/api/products", async (
ProductsQuery query,
CancellationToken cancellationToken) =>
Results.Ok(await query.GetAllAsync(cancellationToken)))
.RequireRateLimiting("public-api");
Com SegmentsPerWindow = 6 e uma janela de um minuto, temos seis segmentos de dez segundos. Uma requisição às 10:00:55 cai no último segmento, então a cota dela só retorna às 10:01:50.
Dessa forma, o problema do pico na fronteira desaparece. Depois de enviar suas 100 requisições, o cliente precisa esperar um minuto completo desde as primeiras.
Mais segmentos deixam o deslizamento mais suave, porém aumentam o uso de memória. Na prática, algo entre 6 e 10 segmentos costuma funcionar bem.
Use a janela deslizante em APIs públicas de leitura, como catálogos e buscas paginadas. Ela também serve como substituição direta da janela fixa, pois as opções são as mesmas, acrescidas de SegmentsPerWindow.
Token bucket: o balde de fichas
Pense agora em uma máquina de fliperama. Ela entrega 5 fichas a cada 10 segundos, mas o seu balde guarda no máximo 20, e as fichas extras se perdem.
Depois de uma pausa, o balde está cheio e você joga 20 partidas seguidas. Em seguida, o balde enche aos poucos, com uma partida a cada dois segundos em média.
Esse é o token bucket do rate limiting no ASP.NET: ele separa o tamanho da rajada da velocidade de reposição.
options.AddTokenBucketLimiter("search", limiter =>
{
limiter.TokenLimit = 20;
limiter.TokensPerPeriod = 5;
limiter.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
limiter.AutoReplenishment = true;
limiter.QueueLimit = 0;
});
app.MapPost("/api/products/search", async (
SearchRequest request,
ProductSearch search,
CancellationToken cancellationToken) =>
Results.Ok(await search.SearchAsync(request.Query, cancellationToken)))
.RequireRateLimiting("search");
Cada requisição consome uma ficha. A cada ReplenishmentPeriod, o limitador adiciona TokensPerPeriod fichas, sem nunca ultrapassar o TokenLimit.
Com AutoReplenishment = true, um timer interno faz a reposição. Caso você desligue essa opção, precisa chamar TryReplenish() manualmente, o que só faz sentido quando você controla o objeto limitador diretamente.
No exemplo acima, a taxa sustentada fica em 30 requisições por minuto. Mesmo assim, a rajada inicial de 20 passa sem nenhuma reclamação.
Esse é o formato de boa parte do tráfego humano: silêncio, depois uma sequência rápida de cliques, depois silêncio de novo. As janelas punem essa sequência, enquanto o balde de fichas a espera.
Use o token bucket em busca, autocomplete e planos com cotas diferentes. Contudo, evite esse algoritmo em proteção contra força bruta, porque um atacante paciente ganha um balde cheio e uma rajada gratuita.
Concurrency limiter: requisições simultâneas
Agora imagine um estacionamento com 10 vagas. A cancela de entrada não se importa com quantos carros chegaram hoje, ela só pergunta quantos estão lá dentro agora.
Esse é o limitador de concorrência do rate limiting no ASP.NET. Em vez de contar quantas requisições chegam por minuto, ele limita quantas rodam ao mesmo tempo.
options.AddConcurrencyLimiter("export", limiter =>
{
limiter.PermitLimit = 3;
limiter.QueueLimit = 5;
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
app.MapGet("/api/reports/export", async (
ReportExporter exporter,
CancellationToken cancellationToken) =>
{
var stream = await exporter.BuildCsvAsync(cancellationToken);
return Results.File(stream, "text/csv", "report.csv");
})
.RequireRateLimiting("export");
Com essa configuração, três exportações rodam juntas. Da quarta à oitava, as requisições aguardam na fila, e a nona recebe um 429.
A permissão volta quando a resposta termina. Portanto, uma exportação de 30 segundos segura a vaga por 30 segundos, enquanto uma resposta de 200 milissegundos a devolve quase de imediato.
Use a concorrência em geração de PDF ou CSV, processamento de imagens, chamadas a modelos de IA e serviços legados com limite de conexões. Uma fila pequena ajuda nesses casos, pois o usuário espera alguns segundos em vez de ver um erro.
Entretanto, esse algoritmo não impede abuso sequencial. Além disso, ele não sabe estimar quando uma vaga vai liberar.
Como escolher o algoritmo de rate limiting no ASP.NET
Antes de escolher, pergunte do que aquele endpoint precisa ser protegido. A tabela abaixo resume as diferenças entre os quatro algoritmos.
| Algoritmo | O que conta | Permite rajadas | Retry-After | Melhor uso |
|---|---|---|---|---|
| Fixed window | Requisições desde o início da janela | Sim, até o dobro na fronteira | Sim | Login e limites simples |
| Sliding window | Requisições nos últimos N segundos | Não | Sim | APIs públicas de leitura |
| Token bucket | Fichas acumuladas | Sim, até o TokenLimit | Sim | Busca e planos por tier |
| Concurrency | Requisições em andamento | Não se aplica | Não | Endpoints pesados |
Em resumo, o rate limiting no ASP.NET responde a quatro perguntas diferentes:
- "N tentativas e depois aguarde": janela fixa.
- "N por minuto, de forma justa, sem trapaça na fronteira": janela deslizante.
- "Rajadas são aceitáveis, mas a média precisa ficar abaixo de X": token bucket.
- "Nunca mais de N rodando ao mesmo tempo": concorrência.
Até aqui, todos os exemplos usaram um único contador para todos os clientes. No entanto, isso raramente atende um ambiente de produção.
Partições por IP, usuário e API key
Um limitador com contador único, na verdade, limita a API inteira. Quando um cliente agressivo esgota a cota, todos os outros recebem 429 até o fim da janela.
No rate limiting no ASP.NET, uma partição é um contador independente, selecionado por uma chave. Para isso, usamos o AddPolicy, que recebe uma função executada a cada requisição.
var loginPermitLimit = builder.Configuration.GetValue("RateLimiting:Login:PermitLimit", 5);
var loginWindowSeconds = builder.Configuration.GetValue("RateLimiting:Login:WindowSeconds", 60);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("login", httpContext =>
{
var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ipAddress,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = loginPermitLimit,
Window = TimeSpan.FromSeconds(loginWindowSeconds),
QueueLimit = 0
});
});
});
A função usa o IP do cliente como chave. O factory executa uma única vez por chave nova, e as requisições seguintes do mesmo IP reaproveitam o mesmo limitador.
Também recomendo manter os limites no appsettings.json. Assim, cada ambiente tem seus próprios valores e os testes de integração conseguem sobrescrevê-los sem alterar o código.
Proxy reverso e o IP real do cliente
Atrás de um load balancer, o RemoteIpAddress traz o endereço do proxy, não o do cliente. Consequentemente, todos os usuários caem na mesma partição e o limitador deixa de funcionar.
A correção vem do middleware de forwarded headers:
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownProxies.Add(IPAddress.Parse("10.0.0.5"));
});
var app = builder.Build();
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
Agora o RemoteIpAddress recebe o valor do cabeçalho X-Forwarded-For. Porém, confie nesse cabeçalho apenas quando ele vem de proxies conhecidos, porque qualquer pessoa pode forjar um valor novo a cada chamada.
Rate limiting no ASP.NET por usuário autenticado
Em endpoints autenticados, a melhor chave é o próprio usuário. O exemplo abaixo usa o identificador vindo das claims e recorre ao IP apenas para chamadas anônimas.
options.AddPolicy("search", httpContext =>
{
var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? httpContext.User.FindFirstValue("sub");
var partitionKey = userId
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
return RateLimitPartition.GetTokenBucketLimiter(
partitionKey: partitionKey,
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 20,
TokensPerPeriod = 5,
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
AutoReplenishment = true,
QueueLimit = 0
});
});
Esse é justamente o motivo de o UseRateLimiter vir depois do UseAuthentication. Se você ainda não configurou tokens na sua API, o artigo sobre Minimal API com autenticação e autorização JWT mostra o caminho completo.
Planos por API key com IRateLimiterPolicy
Em uma API pública com planos, a função de partição precisa identificar o cliente, descobrir o plano e devolver opções diferentes. Nesse caso, uma classe de política fica mais limpa e ainda aceita injeção de dependência.
public sealed class ApiKeyRateLimiterPolicy(
IOptions<RateLimitingSettings> settings,
IApiClientResolver apiClients) : IRateLimiterPolicy<string>
{
public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected => null;
public RateLimitPartition<string> GetPartition(HttpContext httpContext)
{
var apiKey = httpContext.Request.Headers["X-Api-Key"].ToString();
var client = apiClients.Resolve(apiKey);
// Chaves desconhecidas dividem uma partição pequena
if (client is null)
{
return RateLimitPartition.GetFixedWindowLimiter("anonymous",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1)
});
}
// Serviços internos não sofrem limite
if (client.IsInternal)
{
return RateLimitPartition.GetNoLimiter(client.Id);
}
var tier = client.Tier == ClientTier.Pro
? settings.Value.Pro
: settings.Value.Free;
return RateLimitPartition.GetTokenBucketLimiter(client.Id,
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = tier.TokenLimit,
TokensPerPeriod = tier.TokensPerPeriod,
ReplenishmentPeriod = TimeSpan.FromSeconds(tier.ReplenishmentSeconds),
AutoReplenishment = true,
QueueLimit = 0
});
}
}
Depois, basta registrar a política com options.AddPolicy<string, ApiKeyRateLimiterPolicy>("api-key") e aplicar RequireRateLimiting("api-key") nos endpoints.
Um cuidado importante: o GetPartition roda em toda requisição e é síncrono. Logo, a busca pelo cliente precisa vir de um dicionário em memória ou de um cache, nunca de uma consulta ao banco.
Respostas 429 do rate limiting no ASP.NET
Por padrão, o middleware de rate limiting no ASP.NET devolve um 503 Service Unavailable quando rejeita uma requisição. Esse sinal está errado, porque o cliente conclui que o servidor caiu e pode tentar de novo com ainda mais agressividade.
O status correto é o 429 Too Many Requests. Em outras palavras, ele diz ao cliente que o servidor está bem e que é ele quem precisa desacelerar.
O callback OnRejected roda para cada requisição recusada e permite moldar a resposta:
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
var httpContext = context.HttpContext;
var response = httpContext.Response;
TimeSpan? retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var value)
? value
: null;
if (retryAfter is not null)
{
var seconds = (int)Math.Ceiling(retryAfter.Value.TotalSeconds);
response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
}
await response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status429TooManyRequests,
Title = "Too many requests",
Detail = retryAfter is null
? "Rate limit exceeded. Please try again later."
: $"Rate limit exceeded. Retry after {Math.Ceiling(retryAfter.Value.TotalSeconds)} seconds.",
Instance = httpContext.Request.Path
}, cancellationToken);
};
});
O lease que recusou a requisição carrega metadados. Nos limitadores de janela e de balde, esses metadados informam quanto tempo falta até uma nova permissão.
O handler lê esse valor, arredonda para segundos inteiros e preenche o cabeçalho Retry-After. Em seguida, ele escreve um corpo ProblemDetails, mantendo o mesmo formato de erro do restante da API.
O limitador de concorrência nunca envia esse metadado, pois não tem relógio. Nesse cenário, o cabeçalho fica ausente e o cliente deve recuar de forma exponencial, com jitter, para que milhares de clientes não tentem de novo no mesmo segundo.
Vale também acompanhar o .NET 11. Segundo as notas de versão, o FixedWindowRateLimiter passa a informar um RetryAfter alinhado exatamente com a próxima fronteira da janela, sem exigir mudanças no código.
Combinando limitadores com CreateChained
Às vezes, um endpoint precisa de duas proteções ao mesmo tempo. Uma rota de IA, por exemplo, pode exigir um balde de fichas por usuário e um teto de execuções simultâneas.
Para isso, o rate limiting no ASP.NET oferece o método RateLimiter.CreateChained, que combina vários limitadores dentro de uma política nomeada. A requisição precisa obter uma permissão de cada limitador da cadeia, na ordem informada.
Se algum limitador recusar, a requisição falha e as permissões anteriores voltam em ordem inversa. Contudo, apenas o limitador de concorrência devolve a vaga, enquanto os limitadores baseados em tempo mantêm a ficha consumida.
Por isso, coloque o limitador mais barato e mais restritivo no início da cadeia. Dessa forma, você evita gastar fichas de quem nem chegaria a executar.
Rate limiting no ASP.NET com várias instâncias
Existe uma limitação que todo time precisa conhecer. O rate limiting no ASP.NET guarda o estado na memória do processo.
Isso significa que cada instância da aplicação tem seus próprios contadores e que eles zeram a cada reinício. Com três réplicas atrás de um balanceador, um limite de 100 por minuto vira, na prática, até 300.
Essa abordagem funciona bem com uma única instância, com sessões fixas ou com um API gateway na frente. Já em ambientes com várias réplicas, você precisa de um armazenamento compartilhado, como o Redis.
Além disso, faça testes de carga antes de publicar. A própria Microsoft recomenda validar os limites com cuidado, porque um valor mal calibrado bloqueia usuários legítimos com a mesma eficiência com que bloqueia abusos.
Conclusão
Não existe um único limitador correto para todas as rotas. O segredo do rate limiting no ASP.NET está em casar o algoritmo com aquilo de que o endpoint precisa se proteger.
A janela fixa resolve tentativas de login com baixo custo. A janela deslizante garante justiça em APIs públicas, o token bucket acolhe rajadas humanas e o limitador de concorrência protege operações pesadas.
Além disso, particione sempre por IP, usuário ou API key, caso contrário o limite vira global. Por fim, responda com 429, Retry-After e ProblemDetails, para que seus clientes saibam exatamente quando tentar de novo.
Com essas peças no lugar, o rate limiting no ASP.NET deixa de ser um detalhe de configuração e passa a ser uma camada real de resiliência da sua API.
FAQ
Preciso instalar algum pacote para usar rate limiting no ASP.NET?
Não. Desde o .NET 7, os algoritmos e o middleware fazem parte do framework, inclusive no .NET 10.
Qual status HTTP devo retornar quando o limite estoura?
Use o 429 Too Many Requests, configurado em RejectionStatusCode. O padrão 503 sugere que o servidor caiu e incentiva retentativas agressivas.
Qual algoritmo usar em um endpoint de login?
A janela fixa particionada por IP é a escolha mais comum. Ela é barata, fácil de explicar e o pico na fronteira não traz risco relevante nesse cenário.
O limite vale para todas as instâncias da minha aplicação?
Não. O estado fica na memória de cada processo, então ambientes com várias réplicas precisam de um armazenamento compartilhado como o Redis.
Por que o limitador de concorrência não envia Retry-After?
Ele não conta tempo, apenas requisições em andamento. Por isso, não consegue prever quando uma vaga vai liberar.