Rate Limiting em APIs .NET: porque recursos não são infinitos
Um endpoint que chama um LLM é, do ponto de vista de custo, uma torneira: cada requisição gasta tokens que alguém vai pagar. Deixar essa torneira aberta para qualquer cliente, sem limite nenhum, é uma decisão, mesmo que ninguém tenha tomado conscientemente.
O ASP.NET Core resolve isso com o middleware Microsoft.AspNetCore.RateLimiting, disponível desde o .NET 7, sem pacote adicional.
O mínimo para funcionar
São três peças: registrar uma política, ligar o middleware e aplicar a política ao endpoint.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("api", opt =>
{
opt.PermitLimit = 10;
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueLimit = 0;
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapPost("/resumos", () => Results.Ok())
.RequireRateLimiting("api");
O RejectionStatusCode merece atenção. Se ele não for definido, o middleware rejeita com 503 (Service Unavailable), e para quem consome a API um 503 parece falha do servidor, não limite de uso. O 429 é o código que diz o que realmente aconteceu.
Os quatro algoritmos
O middleware traz quatro limitadores, e a diferença entre eles está no que cada um protege.
- Fixed window: conta requisições dentro de uma janela fixa (dez por minuto, por exemplo) e zera quando a janela vira. É o mais simples, com um efeito colateral conhecido: um cliente pode gastar o limite no fim de uma janela e de novo no começo da próxima, concentrando o dobro do esperado num intervalo curto.
- Sliding window: divide a janela em segmentos e a desliza a cada segmento, suavizando esse efeito de borda.
- Token bucket: um balde que enche a uma taxa constante e é esvaziado por requisição. Aceita rajadas até o tamanho do balde, e depois obriga o cliente a esperar a reposição.
- Concurrency: não limita quantas requisições por período, limita quantas estão em andamento ao mesmo tempo. Para chamadas lentas e caras, como um LLM, é frequentemente o que mais protege a aplicação.
A documentação da Microsoft sugere escolher o limitador considerando o custo do endpoint: tempo, acesso a dados, CPU e I/O.
Limitar por quem, e não só por quanto
Um limite global compartilhado por todo mundo deixa um cliente barulhento consumir a cota dos demais. A partição resolve isso, dando a cada chave (usuário, IP, chave de API) o seu próprio contador.
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.User.Identity?.Name ?? "anonimo",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
}));
Dois cuidados que a própria documentação levanta. Particionar por um valor que o cliente controla (um header qualquer, por exemplo) permite criar partições sem fim e esgotar memória. E particionar por IP exige atenção atrás de proxy ou balanceador: sem a configuração de forwarded headers, todo mundo aparece com o IP do proxy.
Avisar o cliente quando o limite chega
Uma resposta 429 sem informação obriga o cliente a adivinhar quando tentar de novo. O OnRejected permite incluir o Retry-After a partir do metadado que o próprio limitador fornece:
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.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsync("Limite de requisições atingido.", cancellationToken);
};
O que o middleware não faz
Os contadores ficam na memória de cada instância da aplicação. Com três instâncias atrás de um balanceador, cada uma aplica o limite por conta própria, e o limite efetivo do cliente passa a ser aproximadamente três vezes o configurado. Para um limite exato entre instâncias, é preciso um armazenamento compartilhado ou aplicar o limite numa camada única na frente.
Também não substitui proteção contra DDoS. A documentação é explícita: rate limiting ajuda contra negação de serviço de origem limitada, mas ataques distribuídos pedem um WAF ou uma CDN com esse tipo de proteção.