Rate Limiting

Protect endpoints with AddRateLimiter policies, partition limits per client, and reject with a 429 and an honest Retry-After header.

Syntaxbuilder.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

LimiterBehaviourRetryAfter metadataGood for
Fixed windowN permits per window, reset at the boundaryYes, the whole window lengthSimple quotas
Sliding windowWindow split into segments; permits return as segments age outNoAvoiding a double burst at the window edge
Token bucketBucket of tokens refilled per period; allows short burstsYes, the replenishment periodAPIs with bursty, well-behaved clients
ConcurrencyAt most N requests in flight, no time componentNoExpensive 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

  • RejectionStatusCode defaults 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-After header is sent unless OnRejected writes it from lease.TryGetMetadata(MetadataName.RetryAfter, ...).
  • GlobalLimiter runs 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · csharp
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.

Example · csharp
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.

Example · csharp
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

  • Be the first to comment on this lesson.