Antes do .NET 7, implementar rate limiting exigia pacotes externos como AspNetCoreRateLimit. A partir do .NET 7 (consolidado no .NET 8/9), o ASP.NET Core tem suporte nativo via Microsoft.AspNetCore.RateLimiting, com quatro algoritmos prontos, políticas nomeadas e integração direta com o pipeline de autenticação. Este artigo cobre tudo que você precisa para proteger suas APIs em produção.
Os quatro algoritmos disponíveis
Cada algoritmo resolve um problema diferente:
- Fixed Window — X requisições por janela fixa de tempo (ex: 100/minuto). Simples, mas sofre de "burst" no limite da janela.
- Sliding Window — Janela deslizante que elimina o burst. Mais preciso, levemente mais custoso.
- Token Bucket — Tokens acumulados até um máximo, consumidos por requisição. Permite bursts controlados (tokens acumulados).
- Concurrency Limiter — Limita requisições simultâneas, não por tempo. Ideal para proteger recursos com limite de conexões.
Setup e configuração
// Program.cs
using System.Threading.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
// Comportamento quando o limite é atingido
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Handler customizado para resposta de rejeição
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = 429;
// Adiciona header Retry-After se disponível
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",
Message = "Limite de requisições atingido. Tente novamente em instantes.",
RetryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var ra)
? (int?)ra.TotalSeconds
: null
}, cancellationToken);
};
// ── Política 1: Fixed Window global ──────────────────────────────────
options.AddFixedWindowLimiter("global", opt =>
{
opt.PermitLimit = 1000; // 1000 requisições
opt.Window = TimeSpan.FromMinutes(1); // por minuto
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
opt.QueueLimit = 0; // Sem fila — rejeita imediatamente
});
// ── Política 2: Sliding Window por IP ────────────────────────────────
options.AddSlidingWindowLimiter("per-ip", opt =>
{
opt.PermitLimit = 100;
opt.Window = TimeSpan.FromMinutes(1);
opt.SegmentsPerWindow = 6; // Divide em segmentos de 10s
opt.QueueLimit = 0;
});
// ── Política 3: Token Bucket para endpoints custosos ─────────────────
options.AddTokenBucketLimiter("expensive-operations", opt =>
{
opt.TokenLimit = 10; // Máximo de 10 tokens
opt.ReplenishmentPeriod = TimeSpan.FromSeconds(10); // Reabastece a cada 10s
opt.TokensPerPeriod = 2; // 2 tokens por período
opt.AutoReplenishment = true;
opt.QueueLimit = 5; // Fila de até 5 requisições
});
// ── Política 4: Concurrency Limiter para DB ───────────────────────────
options.AddConcurrencyLimiter("database-intensive", opt =>
{
opt.PermitLimit = 20; // Máximo de 20 queries simultâneas
opt.QueueProcessingOrder = QueueProcessingOrder.NewestFirst;
opt.QueueLimit = 10;
});
// ── Política 5: Por usuário autenticado (a mais importante) ───────────
options.AddPolicy("per-user", context =>
{
// Usuário autenticado: limite generoso
if (context.User.Identity?.IsAuthenticated == true)
{
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? context.User.FindFirst(ClaimTypes.Email)?.Value
?? "authenticated";
return RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: $"user:{userId}",
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 500,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueLimit = 0
});
}
// Usuário anônimo: limite por IP (mais restritivo)
var ipAddress = context.Connection.RemoteIpAddress?.ToString()
?? context.Request.Headers["X-Forwarded-For"].FirstOrDefault()
?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"ip:{ipAddress}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
});
});
// ── Política 6: Por tenant (multi-tenancy) ────────────────────────────
options.AddPolicy("per-tenant", context =>
{
var tenantId = context.Items["TenantId"]?.ToString()
?? context.Request.Headers["X-Tenant-Id"].FirstOrDefault()
?? "unknown";
// Limite configurável por tenant (ex: plano Basic vs Premium)
var isPremium = context.User.HasClaim("plan", "premium");
return RateLimitPartition.GetTokenBucketLimiter(
partitionKey: $"tenant:{tenantId}",
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = isPremium ? 1000 : 100,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
TokensPerPeriod = isPremium ? 1000 : 100,
AutoReplenishment = true
});
});
});
// Aplicar o middleware
app.UseRateLimiter();
Aplicando políticas nos endpoints
// Minimal APIs — política por endpoint
app.MapGet("/api/products", GetAllProducts)
.RequireRateLimiting("per-user");
app.MapPost("/api/reports/generate", GenerateReport)
.RequireRateLimiting("expensive-operations"); // Token bucket para operações pesadas
app.MapPost("/api/auth/login", Login)
.RequireRateLimiting("per-ip"); // IP-based para endpoints de auth (anti-brute-force)
// Desabilitar rate limiting para endpoints internos
app.MapGet("/health", () => Results.Ok())
.DisableRateLimiting();
// Controllers — atributo
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("per-user")] // Aplica à classe toda
public class OrdersController : ControllerBase
{
[HttpPost]
[EnableRateLimiting("per-tenant")] // Sobrescreve para este endpoint
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request)
{
// ...
}
[HttpGet("export")]
[EnableRateLimiting("expensive-operations")]
public async Task<IActionResult> Export()
{
// ...
}
[HttpGet("/health/orders")]
[DisableRateLimiting] // Sem limite para health check
public IActionResult Health() => Ok();
}
Rate limiting para múltiplas instâncias com Redis
O rate limiting nativo usa contadores em memória — cada instância/pod tem seus próprios contadores. Em Kubernetes com 3 réplicas, o usuário pode fazer 3x o limite permitido. Para distribuir os contadores, use Redis:
dotnet add package RedisRateLimiting
// Rate limiting distribuído com Redis
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = builder.Configuration.GetConnectionString("Redis"));
var redisConnection = ConnectionMultiplexer.Connect(
builder.Configuration.GetConnectionString("Redis")!);
builder.Services.AddSingleton<IConnectionMultiplexer>(redisConnection);
builder.Services.AddRateLimiter(options =>
{
// Sliding window distribuído via Redis
options.AddPolicy("per-user-distributed", context =>
{
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? context.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
return RedisRateLimitPartition.GetSlidingWindowRateLimiter(
partitionKey: $"ratelimit:user:{userId}",
factory: key => new RedisSlidingWindowRateLimiterOptions
{
ConnectionMultiplexerFactory = () =>
context.RequestServices.GetRequiredService<IConnectionMultiplexer>(),
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6
});
});
});
Adicionando headers informativos à resposta
// Middleware que adiciona headers X-RateLimit-* a todas as respostas
public class RateLimitHeadersMiddleware
{
private readonly RequestDelegate _next;
public RateLimitHeadersMiddleware(RequestDelegate next)
=> _next = next;
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
// Adiciona headers informativos se disponíveis
var rateLimitLease = context.Features.Get<IRateLimiterStatisticsFeature>();
if (rateLimitLease is not null)
{
if (rateLimitLease.CurrentAvailablePermits.HasValue)
context.Response.Headers["X-RateLimit-Remaining"] =
rateLimitLease.CurrentAvailablePermits.Value.ToString();
if (rateLimitLease.CurrentWindowDuration.HasValue)
context.Response.Headers["X-RateLimit-Reset"] =
((int)rateLimitLease.CurrentWindowDuration.Value.TotalSeconds).ToString();
}
}
}
// Registrar antes do UseRateLimiter
app.UseMiddleware<RateLimitHeadersMiddleware>();
app.UseRateLimiter();
Testes de rate limiting
// Teste de integração — verifica que o 429 é retornado após o limite
public class RateLimitingTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public RateLimitingTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Limite de 3 requisições para facilitar o teste
services.PostConfigure<RateLimiterOptions>(options =>
{
options.AddFixedWindowLimiter("per-ip", opt =>
{
opt.PermitLimit = 3;
opt.Window = TimeSpan.FromSeconds(60);
});
});
});
}).CreateClient();
}
[Fact]
public async Task RateLimit_ExceedsLimit_Returns429()
{
// Primeiras 3 requisições devem passar
for (int i = 0; i < 3; i++)
{
var response = await _client.GetAsync("/api/products");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
// 4ª deve retornar 429
var limitedResponse = await _client.GetAsync("/api/products");
limitedResponse.StatusCode.Should().Be(HttpStatusCode.TooManyRequests);
limitedResponse.Headers.Should().ContainKey("Retry-After");
}
}
Escolhendo o algoritmo certo
| Caso de uso | Algoritmo recomendado | Por quê |
|---|---|---|
| Proteção geral de API | Sliding Window | Sem burst no limite da janela |
| Anti brute-force (login) | Fixed Window por IP | Simples e eficiente |
| Operações custosas (relatórios) | Token Bucket | Permite acúmulo para bursts ocasionais |
| Proteção de banco/recursos | Concurrency Limiter | Limita concurrent, não por tempo |
| Múltiplos pods/Kubernetes | Sliding Window + Redis | Contadores distribuídos |
| Planos diferenciados (SaaS) | Token Bucket por tenant | Configurável por plano de assinatura |
Rate limiting é a primeira linha de defesa contra abuso de APIs. Se você precisa de uma estratégia de segurança para sua API .NET, fale com a Neryx.