[EnableRateLimiting]

Apply a named rate limiter policy to controllers and endpoints with [EnableRateLimiting], return 429 with Retry-After, and partition limits per client.

Signature
[EnableRateLimiting("per-client")]  |  [DisableRateLimiting]  |  .RequireRateLimiting("per-client")
  • Added in .NET ASP.NET Core 7.0

Parameters

ParameterTypeDescription
$policyNamestringThe name of a policy registered in AddRateLimiter (AddFixedWindowLimiter, AddSlidingWindowLimiter, AddTokenBucketLimiter, AddConcurrencyLimiter or AddPolicy).

Return value

n/aAdds metadata naming the rate limiter policy that the rate limiting middleware (UseRateLimiter) applies to the endpoint. [DisableRateLimiting] removes endpoint and global limiting for an endpoint.

Errors

InvalidOperationException at request time (HTTP 500) when no policy with that name is registered. Without UseRateLimiter() the attribute is silently ignored.

[EnableRateLimiting("name")] attaches a named policy to a controller, an action, a minimal API handler or (as RequireRateLimiting) a route group. The rate limiting middleware reads that metadata after routing has selected the endpoint, asks the policy for a lease and either lets the request through, queues it, or rejects it. [DisableRateLimiting] on an action exempts it from the controller's policy and from the global limiter.

Defaults that bite

  • Rejected requests get 503, not 429. Set RejectionStatusCode = StatusCodes.Status429TooManyRequests; a 503 makes load balancers and clients think the service is down.
  • No Retry-After header is sent. Add it in OnRejected from the lease's MetadataName.RetryAfter.
  • One bucket for everyone. AddFixedWindowLimiter("orders", ...) creates a single counter shared by every client and every endpoint that uses the policy. One noisy client exhausts it for all. Use AddPolicy with a partition key (user id, API key, client IP) for per-client limits.
  • Unknown policy names are not detected at startup; the first request throws and returns 500.
  • Without app.UseRateLimiter() the attribute does nothing at all.

Choosing the limiter

LimiterBehaviourGood for
Fixed windowN requests per window, counter resets at the boundarySimple quotas; allows 2N in a burst across a boundary
Sliding windowWindow split into segmentsSmoother quotas
Token bucketTokens refill at a steady rate; bursts up to the bucket sizeAPIs that allow short bursts
ConcurrencyAt most N requests in flightProtecting an expensive downstream (PDF rendering, a report query)

Production failure modes

The limiter is in memory, per process. Five replicas behind a load balancer allow five times the configured rate, and a deploy resets every counter. Treat it as protection for each instance, not as an exact billing quota; exact per-customer quotas need a shared store such as Redis or an API gateway. Partitioning by RemoteIpAddress behind a proxy puts every client in one partition unless forwarded headers are configured, and partitioning by unbounded keys (for example raw API keys from unauthenticated callers) lets an attacker create unlimited partitions; partition authenticated callers by identity and anonymous ones coarsely.

Example

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

When to use it

  • Allow each API key 3 quote requests per minute and answer the fourth with 429 and a Retry-After header
  • Exempt the status endpoint of a rate-limited controller with [DisableRateLimiting] so uptime monitors never get 429
  • Cap concurrent invoice exports at 4 per instance with a queue of 10 to protect the reporting database

Frequently asked questions

Why do rejected requests return 503?

RateLimiterOptions.RejectionStatusCode defaults to 503 Service Unavailable. Set it to StatusCodes.Status429TooManyRequests, and add Retry-After in OnRejected.

Do two endpoints with the same policy share a limit?

With AddFixedWindowLimiter and the other Add*Limiter helpers, yes: the policy has a single partition shared by every endpoint and client using it. Use AddPolicy with a partition key to separate clients, and include the endpoint in the key if each endpoint needs its own budget.

Is the limit shared across replicas?

No. Counters live in each process's memory, so the effective limit is the configured limit times the number of instances. Use a gateway or a distributed store for exact global quotas.

More examples

Per-user token bucket on a minimal API group

Authenticated users get a bucket of 10 that refills one token every 6 seconds, which allows a short burst but caps the sustained rate at 10 per minute. All anonymous callers share one small partition, so unauthenticated traffic cannot create unlimited partitions. The middleware must run after authentication for <code>HttpContext.User</code> to be set.

Example · csharp
using System.Security.Claims;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("checkout", httpContext =>
    {
        var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
        return userId is null
            ? RateLimitPartition.GetFixedWindowLimiter("anonymous",
                _ => new FixedWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(1) })
            : RateLimitPartition.GetTokenBucketLimiter(userId, _ => new TokenBucketRateLimiterOptions
            {
                TokenLimit = 10,
                TokensPerPeriod = 1,
                ReplenishmentPeriod = TimeSpan.FromSeconds(6),
                QueueLimit = 0,
            });
    });
});

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();   // after authentication, so the partition can use the user id

var checkout = app.MapGroup("/checkout").RequireAuthorization().RequireRateLimiting("checkout");
checkout.MapPost("/", () => TypedResults.Accepted("/checkout/status"));

app.Run();

Limits from configuration

Keeping the numbers in configuration lets operations tune limits per environment without a rebuild. Read the values with <code>builder.Configuration.GetSection("RateLimiting")</code> inside <code>AddRateLimiter</code>; a policy reads them once when its limiters are created, so a change takes effect on restart.

Example · json
{
  // appsettings.json: bound to RateLimitSettings and read when the policy is built
  "RateLimiting": {
    "PerClient": {
      "PermitLimit": 120,
      "WindowSeconds": 60
    },
    "Reports": {
      "MaxConcurrent": 4
    }
  }
}

Concurrency limit for an expensive export

A concurrency limiter is the right tool when the cost is in parallel work, not request count: it protects CPU and the database from ten simultaneous exports. Here a shared single partition is intended, because the resource being protected is the instance itself.

Example · csharp
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    // At most 4 exports render at once per instance; 10 more may wait in line.
    options.AddConcurrencyLimiter("exports", o =>
    {
        o.PermitLimit = 4;
        o.QueueLimit = 10;
        o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
    });
});

var app = builder.Build();
app.UseRateLimiter();
app.MapControllers();
app.Run();

[ApiController]
[Route("api/exports")]
public class ExportsController : ControllerBase
{
    [HttpPost("invoices")]
    [EnableRateLimiting("exports")]
    public async Task<IActionResult> Invoices(CancellationToken cancellationToken)
    {
        await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);   // stands in for rendering
        return Accepted();
    }
}

Discussion

  • Be the first to comment on this lesson.