Rate limiting en ASP.NET Core: cómo proteger tu API
Un cliente con un bucle mal escrito puede tumbar la API para todos. Rate limiting en ASP.NET Core: algoritmos, límites por cliente y errores con proxies.
Una integración de un cliente tiene un error: reintenta sin espera cada vez que recibe un error. Un día la API tiene un problema de 30 segundos y esa integración responde con 4.000 peticiones por segundo. El problema de 30 segundos se convierte en una caída de una hora, para todos los clientes.
El rate limiting existe para que un solo consumidor, por error o a propósito, no pueda agotar la capacidad que comparten todos. Desde .NET 7 viene incluido en ASP.NET Core.
Configuración básica
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("public", limiter =>
{
limiter.PermitLimit = 100; // 100 peticiones...
limiter.Window = TimeSpan.FromMinutes(1); // ...por minuto
limiter.QueueLimit = 0; // sin cola: se rechaza de inmediato
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/api/products", GetProducts).RequireRateLimiting("public");
Con controladores, el atributo [EnableRateLimiting("public")] hace lo mismo, y [DisableRateLimiting] excluye un endpoint.
Por defecto, el rechazo es un 503. Cambiarlo a 429 Too Many Requests es importante: los clientes HTTP bien escritos saben que un 429 significa "espera y reintenta", no "el servicio está caído".
Los cuatro algoritmos
| Algoritmo | Cómo funciona | Bueno para |
|---|---|---|
| Fixed window | N peticiones por ventana fija (ej. por minuto) | Límites simples y fáciles de explicar |
| Sliding window | Ventana dividida en segmentos que se desplazan | Evitar ráfagas en el cambio de ventana |
| Token bucket | Se recargan fichas a ritmo constante; cada petición gasta una | Permitir ráfagas cortas con un promedio controlado |
| Concurrency | Máximo de peticiones simultáneas | Proteger operaciones costosas |
El fixed window tiene un problema conocido: un cliente puede hacer 100 peticiones en el último segundo de un minuto y 100 en el primero del siguiente, 200 en dos segundos. Si eso importa, sliding window o token bucket.
El limitador por concurrencia es distinto: no cuenta peticiones por tiempo, sino cuántas están en curso. Es ideal para un endpoint que genera reportes pesados:
options.AddConcurrencyLimiter("reports", limiter =>
{
limiter.PermitLimit = 4; // máximo 4 reportes generándose a la vez
limiter.QueueLimit = 10; // hasta 10 esperando turno
});
Límites por cliente, no globales
Un límite global de 100 peticiones por minuto significa que un solo cliente puede consumir las 100 y dejar sin servicio a los demás. Lo útil casi siempre es un límite por cliente:
options.AddPolicy("per-user", context =>
{
var key = context.User.Identity?.IsAuthenticated == true
? context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown"
: context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetTokenBucketLimiter(key, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 50, // ráfaga máxima
TokensPerPeriod = 10, // recarga...
ReplenishmentPeriod = TimeSpan.FromSeconds(1), // ...por segundo
QueueLimit = 0
});
});
Usuarios autenticados por su identificador; anónimos por IP. También se puede particionar por API key, por cliente de una integración o por plan de suscripción (más permisos para el plan pago).
El error más común: la IP detrás de un proxy
Si la API está detrás de un balanceador, un reverse proxy o un CDN, RemoteIpAddress es la IP del proxy, no la del cliente. Todos los clientes comparten la misma partición y el límite por IP se convierte en un límite global.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownProxies.Add(IPAddress.Parse("10.0.0.4")); // solo confiar en tu proxy
});
app.UseForwardedHeaders(); // antes de UseRateLimiter
Configurar los proxies conocidos es esencial: si confías en X-Forwarded-For de cualquier origen, un atacante puede enviar una IP distinta en cada petición y evadir el límite.
Decirle al cliente cuándo reintentar
options.OnRejected = async (context, ct) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
await context.HttpContext.Response.WriteAsJsonAsync(new
{
title = "Demasiadas peticiones",
status = 429
}, ct);
};
El encabezado Retry-After permite que los clientes esperen lo justo en lugar de reintentar a ciegas.
Varias instancias: el límite es por servidor
El limitador de ASP.NET Core guarda sus contadores en memoria. Con 4 instancias detrás de un balanceador, un límite de 100 por minuto es, en la práctica, de hasta 400. Para la mayoría de casos de protección eso es aceptable. Si necesitas un límite exacto entre instancias (por ejemplo, porque se cobra por uso), hace falta un almacén compartido como Redis, o aplicarlo antes, en el API Gateway.
El trade-off
Un límite muy bajo rechaza tráfico legítimo, y los clientes lo viven como errores. Uno muy alto no protege nada. Y cada rechazo es un cliente que tiene que reintentar, con la complejidad que eso implica para quien integra.
Empieza con límites generosos, mide el uso real por cliente durante unas semanas y ajusta. Los límites deben proteger contra el abuso, no contra el uso normal.
Cuándo no hacerlo en la aplicación
- Si ya tienes un API Gateway (Azure API Management, un ingress con límites, Cloudflare), el límite global suele estar mejor ahí: rechaza el tráfico antes de que consuma recursos de tu aplicación.
- Para protección contra ataques de denegación de servicio a gran escala: eso se resuelve en la red, no en el código.
El rate limiting en la aplicación sigue siendo útil para reglas que dependen del usuario, del plan o del endpoint, que el gateway no conoce.
Conclusión
Límites por cliente, 429 con Retry-After, IP real detrás del proxy y un limitador por concurrencia para lo más costoso. Con eso, un cliente con un bucle mal escrito afecta solo a sí mismo. Y si el endpoint protegido es costoso por una consulta lenta, recuerda que la caché y la optimización atacan la causa, mientras el límite solo protege del exceso.
¿Necesitas un equipo .NET para tu proyecto?
Construimos y modernizamos backends en .NET y SQL Server: APIs, integraciones y sistemas empresariales a medida.
Ver desarrollo a medida