Un API fără rate limiting este un API care poate fi doborât de oricine — intenționat sau accidental. Un client cu un bug în cod care face 10.000 de request-uri pe minut, un script de scraping, un atac de tip brute-force pe endpoint-ul de autentificare — toate produc același efect: resurse epuizate, latență crescută, serviciu degradat pentru utilizatorii legitimi.
Până în .NET 7, rate limiting necesita librării externe (AspNetCoreRateLimit, etc.) sau soluții custom. Începând cu .NET 8 și rafinat în versiunile ulterioare, framework-ul include un sistem de rate limiting complet, flexibil și bine integrat cu middleware pipeline-ul ASP.NET Core. Toate exemplele din acest articol sunt valide pe .NET 10.
1. Cei patru algoritmi de rate limiting
Înainte de orice cod, merită să înțelegi ce algoritm se potrivește fiecărui scenariu. Alegerea greșită produce fie protecție insuficientă, fie frustrarea utilizatorilor legitimi.
Fixed Window
Permite un număr fix de request-uri într-o fereastră de timp fixă (ex: 100 request-uri / minut). La expirarea ferestrei, contorul se resetează complet.
Avantaj: simplu, predictibil, ușor de comunicat utilizatorilor.
Dezavantaj: vulnerable la burst attack — un client poate face 100 de request-uri în ultimele 5 secunde ale ferestrei și 100 în primele 5 secunde ale ferestrei următoare: 200 de request-uri în 10 secunde, deși limita e 100/minut.
Sliding Window
Similar cu Fixed Window, dar fereastra se deplasează în timp real față de ultimul request. Elimină burst attack-ul de la granița ferestrei.
Avantaj: distribuție mai uniformă a request-urilor.
Dezavantaj: mai costisitor în memorie (trebuie să ții timestamps-urile request-urilor recente).
Token Bucket
Un „coș” cu un număr maxim de token-uri. Fiecare request consumă un token. Token-urile se regenerează cu o rată constantă. Permite burst-uri scurte (dacă coșul e plin), dar limitează rata medie pe termen lung.
Avantaj: cel mai natural pentru utilizare umană reală — un utilizator poate face câteva request-uri rapide, dar nu poate susține o rată ridicată la nesfârșit.
Dezavantaj: mai complex de comunicat utilizatorilor (când se reîncarcă exact token-urile?).
Concurrency Limiter
Limitează numărul de request-uri procesate simultan, nu rata în timp. Nu contorizează request-uri pe secundă, ci câte sunt active în același moment.
Avantaj: protecție directă contra supraîncărcării resurselor (conexiuni DB, memorie, CPU).
Dezavantaj: nu previne abuzul pe termen lung dacă request-urile sunt scurte.
2. Setup de bază
Rate limiting este disponibil în System.Threading.RateLimiting (built-in) și integrat în ASP.NET Core prin middleware.
2.1 Fixed Window — exemplu de start
// Program.cs
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", limiterOptions =>
{
limiterOptions.PermitLimit = 100; // max request-uri
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 10; // request-uri la coadă
});
// Răspuns 429 custom
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
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 = "Ai depășit limita de request-uri. Încearcă din nou mai târziu."
}, cancellationToken);
};
});
// IMPORTANT: UseRateLimiter înainte de UseRouting / MapControllers
app.UseRateLimiter();
app.MapControllers();
2.2 Aplicare pe endpoint
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
[EnableRateLimiting("fixed")] // aplică politica "fixed"
public IActionResult GetAll() => Ok();
[HttpGet("public")]
[DisableRateLimiting] // exclude explicit de la orice rate limiting
public IActionResult GetPublic() => Ok();
}
2.3 Aplicare globală pe toate endpoint-urile
builder.Services.AddRateLimiter(options =>
{
// Politica globală — se aplică tuturor endpoint-urilor
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
httpContext => RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 200,
Window = TimeSpan.FromMinutes(1)
}));
});
3. Sliding Window
builder.Services.AddRateLimiter(options =>
{
options.AddSlidingWindowLimiter("sliding", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.SegmentsPerWindow = 6; // fereastră împărțită în 6 segmente de 10s
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 5;
});
});
SegmentsPerWindow controlează granularitatea ferestrei glisante. Cu 6 segmente pe un minut, fereastra se actualizează la fiecare 10 secunde — mai fin decât Fixed Window, mai puțin costisitor decât timestamp per request.
4. Token Bucket
builder.Services.AddRateLimiter(options =>
{
options.AddTokenBucketLimiter("token-bucket", limiterOptions =>
{
limiterOptions.TokenLimit = 50; // capacitate maximă coș
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
limiterOptions.TokensPerPeriod = 10; // 10 token-uri la fiecare 10s = 1/s
limiterOptions.AutoReplenishment = true; // reîncărcare automată în background
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 5;
});
});
Configurarea de mai sus permite burst-uri de până la 50 de request-uri dacă coșul e plin, dar rata medie susținută e de 1 request/secundă. Ideal pentru endpoint-uri care trebuie să fie responsive la interacțiuni umane normale, dar să blocheze scripturi automatizate.
5. Concurrency Limiter
builder.Services.AddRateLimiter(options =>
{
options.AddConcurrencyLimiter("concurrency", limiterOptions =>
{
limiterOptions.PermitLimit = 20; // max 20 request-uri simultane
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 10;
});
});
Potrivit pentru endpoint-uri care fac operații costisitoare: generare rapoarte, procesare imagini, apeluri spre servicii externe lente. Limitezi câte se procesează simultan, nu câte vin pe secundă.
6. Rate limiting per utilizator — PartitionedRateLimiter
Scenariul cel mai comun în producție: limite diferite pentru utilizatori autentificați vs. anonimi, sau limite per plan de abonament.
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("per-user", httpContext =>
{
var user = httpContext.User;
// Utilizator autentificat — limită mai generoasă
if (user.Identity?.IsAuthenticated == true)
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)
?? "authenticated-unknown";
// Limită per plan de abonament
var plan = user.FindFirstValue("subscription_plan") ?? "basic";
var limit = plan switch
{
"pro" => 1000,
"business" => 5000,
_ => 100 // basic
};
return RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: $"user:{userId}",
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = limit,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6
});
}
// Utilizator anonim — limitat per IP
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"anon:{ip}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1)
});
});
options.OnRejected = async (context, ct) =>
{
context.HttpContext.Response.StatusCode = 429;
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 = "Rate limit exceeded.",
retryAfterSeconds = retryAfter.TotalSeconds
}, ct);
};
});
Aplicare pe controller
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("per-user")]
public class ApiController : ControllerBase
{
// Toate endpoint-urile din controller respectă politica "per-user"
}
7. Rate limiting pe endpoint-uri critice — autentificare și înregistrare
Endpoint-urile de autentificare sunt ținta principală a atacurilor brute-force. Trebuie tratate separat, cu limite mult mai stricte:
builder.Services.AddRateLimiter(options =>
{
// Endpoint de login — strict, per IP
options.AddFixedWindowLimiter("auth-strict", limiterOptions =>
{
limiterOptions.PermitLimit = 5; // 5 încercări
limiterOptions.Window = TimeSpan.FromMinutes(15); // per 15 minute
limiterOptions.QueueLimit = 0; // fără coadă
});
// Endpoint de înregistrare — moderat, per IP
options.AddFixedWindowLimiter("register-moderate", limiterOptions =>
{
limiterOptions.PermitLimit = 3;
limiterOptions.Window = TimeSpan.FromHours(1);
limiterOptions.QueueLimit = 0;
});
// Reset parolă — foarte strict
options.AddFixedWindowLimiter("password-reset", limiterOptions =>
{
limiterOptions.PermitLimit = 3;
limiterOptions.Window = TimeSpan.FromHours(24);
limiterOptions.QueueLimit = 0;
});
});
[HttpPost("login")]
[EnableRateLimiting("auth-strict")]
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] LoginDto dto) { ... }
[HttpPost("register")]
[EnableRateLimiting("register-moderate")]
[AllowAnonymous]
public async Task<IActionResult> Register([FromBody] RegisterDto dto) { ... }
[HttpPost("forgot-password")]
[EnableRateLimiting("password-reset")]
[AllowAnonymous]
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordDto dto) { ... }
Atenție la IP spoofing: Dacă aplicația ta e în spatele unui proxy sau load balancer,
RemoteIpAddressva fi întotdeauna IP-ul proxy-ului. Trebuie să citești IP-ul real din header-ulX-Forwarded-ForsauX-Real-IP, configurat prinForwardedHeadersmiddleware.
// Program.cs — citire IP real din spatele proxy-ului
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// Restricționează la IP-urile proxy-urilor de încredere
options.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));
});
app.UseForwardedHeaders();
app.UseRateLimiter(); // după ForwardedHeaders
8. Răspunsul 429 și headers standard
Un răspuns 429 bine format permite clienților să se comporte inteligent — să aștepte exact cât trebuie înainte de a reîncerca:
options.OnRejected = async (context, cancellationToken) =>
{
var response = context.HttpContext.Response;
response.StatusCode = StatusCodes.Status429TooManyRequests;
response.ContentType = "application/json";
// Retry-After: câte secunde să aștepte clientul
if (context.Lease.TryGetMetadata(
MetadataName.RetryAfter, out var retryAfter))
{
response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
}
// Loghează pentru monitorizare
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
var ip = context.HttpContext.Connection.RemoteIpAddress;
var path = context.HttpContext.Request.Path;
var user = context.HttpContext.User.Identity?.Name ?? "anonymous";
logger.LogWarning(
"Rate limit exceeded. IP: {IP}, Path: {Path}, User: {User}",
ip, path, user);
await response.WriteAsJsonAsync(new
{
type = "https://tools.ietf.org/html/rfc6585#section-4",
title = "Too Many Requests",
status = 429,
detail = "Ai depășit limita de request-uri permise.",
retryAfterSeconds = retryAfter.TotalSeconds
}, cancellationToken);
};
9. Rate limiting distribuit — multiple instanțe
Rate limiting-ul built-in este in-process — stochează contoarele în memorie. Dacă ai multiple instanțe ale aplicației (scale-out, Kubernetes), fiecare instanță are propriile contoare — un client poate face de N ori mai multe request-uri decât limita dacă ajunge pe instanțe diferite.
Soluții pentru rate limiting distribuit:
9.1 Redis cu RedisRateLimiting
dotnet add package RedisRateLimiting
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration
.GetConnectionString("Redis");
});
builder.Services.AddRateLimiter(options =>
{
var redisConnection = ConnectionMultiplexer.Connect(
builder.Configuration.GetConnectionString("Redis")!);
options.AddRedisSlidingWindowLimiter("distributed-sliding",
limiterOptions =>
{
limiterOptions.ConnectionMultiplexerFactory = () => redisConnection;
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
});
});
9.2 Azure API Management
Dacă folosești Azure API Management ca gateway, rate limiting-ul poate fi configurat la nivel de gateway — înainte ca request-ul să ajungă la aplicație, indiferent de câte instanțe backend există:
<!-- Politică APIM: 100 request-uri per minut per subscription key -->
<rate-limit-by-key
calls="100"
renewal-period="60"
counter-key="@(context.Subscription.Id)" />
<!-- SAU per IP -->
<rate-limit-by-key
calls="20"
renewal-period="60"
counter-key="@(context.Request.IpAddress)" />
APIM și rate limiting-ul din aplicație pot coexista — APIM face protecție la nivel macro (per client/plan), aplicația face protecție granulară (per endpoint specific).
10. Rate limiting combinat — politici multiple pe același endpoint
Poți combina mai mulți limiteri pentru protecție în straturi. De exemplu: limită per IP și limită globală simultan:
builder.Services.AddRateLimiter(options =>
{
// Limita globală — protejează resursele serverului
options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
// Strat 1: limită per IP
PartitionedRateLimiter.Create<HttpContext, string>(
ctx => RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 200,
Window = TimeSpan.FromMinutes(1)
})),
// Strat 2: limită globală totală (toate IP-urile)
PartitionedRateLimiter.Create<HttpContext, string>(
_ => RateLimitPartition.GetConcurrencyLimiter(
partitionKey: "global",
factory: _ => new ConcurrencyLimiterOptions
{
PermitLimit = 500,
QueueLimit = 0
}))
);
});
11. Testare rate limiting
[TestFixture]
public class RateLimitingTests
{
private WebApplicationFactory<Program> _factory = default!;
[SetUp]
public void Setup()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Override cu limite mici pentru teste
services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed",
o =>
{
o.PermitLimit = 3;
o.Window = TimeSpan.FromSeconds(10);
o.QueueLimit = 0;
});
options.OnRejected = async (ctx, ct) =>
{
ctx.HttpContext.Response.StatusCode = 429;
await Task.CompletedTask;
};
});
});
});
}
[Test]
public async Task RateLimit_ExceedingLimit_Returns429()
{
var client = _factory.CreateClient();
// Primele 3 request-uri trebuie să treacă
for (int i = 0; i < 3; i++)
{
var response = await client.GetAsync("/api/products");
Assert.That(response.StatusCode,
Is.Not.EqualTo(HttpStatusCode.TooManyRequests));
}
// Al 4-lea trebuie respins
var rejected = await client.GetAsync("/api/products");
Assert.That(rejected.StatusCode,
Is.EqualTo(HttpStatusCode.TooManyRequests));
}
[TearDown]
public void TearDown() => _factory.Dispose();
}
12. Probleme comune și soluțiile lor
| Problemă | Cauza probabilă | Soluție |
|---|---|---|
| Rate limiting nu funcționează | app.UseRateLimiter() lipsește sau e în ordinea greșită |
Adaugă înainte de app.MapControllers() / app.UseRouting() |
| Toți utilizatorii primesc 429, nu doar abuzatorii | IP-ul proxy-ului e folosit ca partition key | Configurează ForwardedHeaders middleware înainte de rate limiter |
| Limite diferite între instanțe (scale-out) | Contoare in-process, nu distribuite | Migrează la Redis cu RedisRateLimiting sau folosește APIM |
| Clientul nu știe când să reîncerce | Retry-After header lipsește din răspunsul 429 |
Adaugă MetadataName.RetryAfter în OnRejected |
| Endpoint-uri interne blocat de rate limiting | Health checks, metrics endpoints limitate | Adaugă [DisableRateLimiting] pe health check endpoints sau exclude prin path |
13. Checklist de producție
- ✅
app.UseRateLimiter()înregistrat în ordinea corectă în pipeline - ✅
ForwardedHeadersmiddleware configurat dacă aplicația e în spatele unui proxy - ✅ Endpoint-uri de autentificare (
/login,/register,/forgot-password) cu limite stricte separate - ✅ Răspuns 429 cu header
Retry-Afterși body JSON structurat - ✅ Logging în
OnRejectedcu IP, path și utilizator pentru monitorizare - ✅ Limite diferite pentru utilizatori autentificați vs. anonimi
- ✅ Limite per plan de abonament dacă aplicația are mai multe tiere
- ✅ Redis sau APIM pentru deployment cu multiple instanțe
- ✅
[DisableRateLimiting]pe health check și metrics endpoints - ✅ Teste de integrare care verifică comportamentul la depășirea limitei
- ✅ Alertă în Application Insights / Azure Monitor când rata de 429 depășește un prag
Concluzie
Rate limiting-ul built-in din .NET 10 elimină nevoia de librării externe pentru cele mai comune scenarii. Cei patru algoritmi acoperă cazuri diferite: Fixed Window pentru simplitate, Sliding Window pentru distribuție uniformă, Token Bucket pentru comportament uman natural, Concurrency Limiter pentru protecție la nivel de resurse.
Cea mai importantă decizie de arhitectură: in-process sau distribuit. Dacă rulezi o singură instanță, built-in e suficient. Dacă scalezi orizontal, ai nevoie de Redis sau un gateway (APIM, Nginx) care centralizează contoarele.
Seria de securitate se încheie cu ultimul articol: Audit Logging și Security Events — cum înregistrezi ce se întâmplă în sistem astfel încât să poți detecta atacuri, să investighezi incidente și să demonstrezi conformitate.