[EnableRateLimiting]
Apply a named rate limiter policy to controllers and endpoints with [EnableRateLimiting], return 429 with Retry-After, and partition limits per client.
[EnableRateLimiting("per-client")] | [DisableRateLimiting] | .RequireRateLimiting("per-client")- Added in .NET ASP.NET Core 7.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$policyName | string | The 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
OnRejectedfrom the lease'sMetadataName.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. UseAddPolicywith 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
| Limiter | Behaviour | Good for |
|---|---|---|
| Fixed window | N requests per window, counter resets at the boundary | Simple quotas; allows 2N in a burst across a boundary |
| Sliding window | Window split into segments | Smoother quotas |
| Token bucket | Tokens refill at a steady rate; bursts up to the bucket size | APIs that allow short bursts |
| Concurrency | At most N requests in flight | Protecting 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
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.
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.
{
// 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.
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