Rate Limiting
Protect endpoints with AddRateLimiter policies, partition limits per client, and reject with a 429 and an honest Retry-After header.
builder.Services.AddRateLimiter(o => {
o.RejectionStatusCode = 429;
o.AddFixedWindowLimiter("orders", w => { w.PermitLimit = 100; w.Window = TimeSpan.FromMinutes(1); });
});
app.UseRateLimiter();
orders.RequireRateLimiting("orders");The rate limiting middleware asks a limiter for a permit before the endpoint runs. If it gets one, the request continues; if not, the request is rejected or waits in a queue. Limiters come from System.Threading.RateLimiting; ASP.NET Core adds the middleware, named policies and partitioning. Register with builder.Services.AddRateLimiter, add app.UseRateLimiter() after authentication when partitions depend on the user, and attach policies with RequireRateLimiting("name"), [EnableRateLimiting("name")] or DisableRateLimiting().
The four algorithms
| Limiter | Behaviour | RetryAfter metadata | Good for |
|---|---|---|---|
| Fixed window | N permits per window, reset at the boundary | Yes, the whole window length | Simple quotas |
| Sliding window | Window split into segments; permits return as segments age out | No | Avoiding a double burst at the window edge |
| Token bucket | Bucket of tokens refilled per period; allows short bursts | Yes, the replenishment period | APIs with bursty, well-behaved clients |
| Concurrency | At most N requests in flight, no time component | No | Expensive exports, slow downstream calls |
A fixed window of 100 per minute can admit 200 requests in two seconds across a boundary; sliding windows and token buckets smooth that out. QueueLimit defaults to 0. Queueing makes clients wait instead of failing fast, which mostly helps background callers; for interactive APIs keep it at 0.
Defaults that surprise
RejectionStatusCodedefaults to 503. Set 429, or clients retry as if the server were down and alerts fire for an outage that is not one.- No
Retry-Afterheader is sent unlessOnRejectedwrites it fromlease.TryGetMetadata(MetadataName.RetryAfter, ...). GlobalLimiterruns for every request before the endpoint policy, so both must grant a permit.
Partitioning
One limiter for everybody lets a single client starve the rest. RateLimitPartition.GetFixedWindowLimiter(key, factory) creates one limiter per key: an authenticated user id, a tenant, a validated API client, or the client IP for anonymous traffic. Partition by a value you have verified. Raw header values let an attacker mint a fresh partition per request, and the IP is useless behind a proxy unless UseForwardedHeaders runs first; otherwise every client shares the load balancer's address and one busy customer locks out everyone.
What the limits really are
State lives in process memory. Four replicas with 100 per minute each allow up to 400 per minute per client, and a restart resets every counter. That is right for protecting the instance itself: CPU, the database pool, a costly report. Contractual quotas across the fleet ("10,000 calls a day on the Basic plan") belong in the API gateway or in a shared store.
Production failure modes
- A global limiter that also counts Kubernetes probes, so a traffic spike fails liveness checks and the pods restart.
- Partitioning by IP behind an ingress without forwarded headers.
- Rejections not logged or measured, so a customer complains about 429s nobody can see.
Example
When to use it
- Give each API client 100 POST /orders per minute so one integrator's retry storm cannot exhaust the database pool for everyone else.
- Cap CSV exports at four concurrent requests per replica and answer the fifth with 429, a ProblemDetails body and Retry-After.
- Throttle anonymous login attempts per client IP behind an ingress, with forwarded headers so the IP is the customer's and not the proxy's.
More examples
A global limiter that spares health probes
<code>CreateChained</code> requires a permit from every limiter in order, so a request must pass both the per-IP sliding window and the instance-wide concurrency cap. <code>DisableRateLimiting()</code> on the probe endpoint skips the global limiter entirely, so a traffic spike cannot fail liveness checks and restart healthy pods. Returning <code>GetNoLimiter</code> from only the per-IP partitioner would not be enough: the chained concurrency limiter still counts the probe and answers 429 once 200 requests are in flight. Without <code>UseForwardedHeaders</code> every request would share the ingress controller's address as its partition.
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.244.0.0/16"));
});
builder.Services.AddHealthChecks();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
var perClientIp = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetSlidingWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 600,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6
}));
// Protects the instance as a whole: never more than 200 requests in flight.
var wholeInstance = PartitionedRateLimiter.Create<HttpContext, string>(_ =>
RateLimitPartition.GetConcurrencyLimiter("instance",
_ => new ConcurrencyLimiterOptions { PermitLimit = 200, QueueLimit = 0 }));
options.GlobalLimiter = PartitionedRateLimiter.CreateChained(perClientIp, wholeInstance);
});
var app = builder.Build();
app.UseForwardedHeaders(); // RemoteIpAddress must be the client, not the ingress
app.UseRateLimiter();
// Skips the whole GlobalLimiter chain, not just one limiter in it.
app.MapHealthChecks("/healthz").DisableRateLimiting();
app.MapGet("/products", () => TypedResults.Ok(new[] { new { id = 1, name = "Grinder" } }));
app.Run();Per-user token bucket on controllers
The token bucket lets a user's dashboard fire 20 requests at once on page load and then sustain 5 per second, which a fixed window cannot express. The middleware must come after <code>UseAuthentication</code>, otherwise <code>context.User</code> is empty when the partition key is computed and every caller lands in the shared <code>anonymous</code> bucket.
using System.Security.Claims;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddAuthentication().AddJwtBearer(options => options.MapInboundClaims = false);
builder.Services.AddAuthorization();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("per-user", context =>
{
// Runs after UseAuthentication, so User is the validated token's subject.
// With MapInboundClaims = false the id stays "sub"; NameIdentifier would be null (see claims transformation).
var userId = context.User.FindFirstValue("sub") ?? "anonymous";
return RateLimitPartition.GetTokenBucketLimiter(userId, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 20, // burst size
TokensPerPeriod = 5,
ReplenishmentPeriod = TimeSpan.FromSeconds(1), // 5 per second sustained
QueueLimit = 0
});
});
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapControllers();
app.Run();
[ApiController]
[Route("api/invoices")]
[Authorize]
[EnableRateLimiting("per-user")]
public class InvoicesController : ControllerBase
{
[HttpGet]
public IActionResult List() => Ok(new[] { new { number = "INV-2026-0042", total = 980.00m } });
// Downloading an already generated PDF is cheap; exclude it from the user's bucket.
[HttpGet("{number}/pdf")]
[DisableRateLimiting]
public IActionResult Pdf(string number) => File(Array.Empty<byte>(), "application/pdf", $"{number}.pdf");
}Concurrency limit with a ProblemDetails rejection
A concurrency limiter has no time window, so its lease has no <code>RetryAfter</code> metadata and the handler sets a fixed hint itself. Writing the rejection through <code>IProblemDetailsService</code> keeps 429 bodies in the same shape as every other error. Logging in <code>OnRejected</code> matters: rejections never reach the endpoint, so without it they are invisible apart from the status code in access logs.
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddConcurrencyLimiter("exports", limiter =>
{
limiter.PermitLimit = 4; // the reporting replica handles four exports at a time
limiter.QueueLimit = 0;
});
options.OnRejected = async (context, cancellationToken) =>
{
var http = context.HttpContext;
http.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("RateLimiting")
.LogWarning("Rejected {Method} {Path} for {User}", http.Request.Method, http.Request.Path,
http.User.Identity?.Name ?? "anonymous");
http.Response.Headers.RetryAfter = "10"; // concurrency leases carry no RetryAfter metadata
await http.RequestServices.GetRequiredService<IProblemDetailsService>().WriteAsync(new ProblemDetailsContext
{
HttpContext = http,
ProblemDetails = new ProblemDetails
{
Status = StatusCodes.Status429TooManyRequests,
Title = "Too many concurrent exports",
Detail = "Four exports are already running. Try again shortly."
}
});
};
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/reports/sales.csv", async (CancellationToken ct) =>
{
await Task.Delay(TimeSpan.FromSeconds(20), ct); // stands in for a heavy query
return TypedResults.Text("orderId,total\n1001,129.90\n", "text/csv");
}).RequireRateLimiting("exports");
app.Run();
Discussion