Rate limiting is one of those features that usually works quietly in the background.
An API receives requests, the rate limiter counts them, and once the configured limit is reached, additional requests receive a 429 Too Many Requests response.
That part is straightforward.
The more interesting question is what the client should do next.
If the server returns only:
HTTP/1.1 429 Too Many Requests
the client knows that it has been rate limited, but it does not know when it should try again.
This is where the Retry-After HTTP response header becomes useful.
ASP.NET Core 11 improves the rate-limiting middleware by making the RetryAfter metadata from FixedWindowRateLimiter accurately represent the next rate-limit window boundary. Applications that copy this metadata into the Retry-After response header can now tell clients when to retry more accurately.
This sounds like a small change, but it matters when APIs have automated clients, SDKs, background jobs, mobile applications, or other services that need to react correctly to rate limits.
What Is Rate Limiting?
Rate limiting controls how many requests a client can make during a particular period.
For example:
10 requests
per
1 minute
A client can make the first 10 requests successfully.
The next request is rejected:
Request 11
|
v
429 Too Many Requests
A simple fixed-window policy can be configured in ASP.NET Core like this:
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("api", limiterOptions =>
{
limiterOptions.PermitLimit = 10;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
});
});
Then apply the policy to an endpoint:
app.MapGet("/products", () =>
{
return Results.Ok("Products");
})
.RequireRateLimiting("api");
The application can now restrict how frequently that endpoint is called.
What Happens When the Limit Is Reached?
Suppose the policy allows:
10 requests / minute
The client sends:
Request 1 → 200
Request 2 → 200
Request 3 → 200
...
Request 10 → 200
Request 11 → 429
The client needs to know what to do with request 11.
A useful response is:
HTTP/1.1 429 Too Many Requests
Retry-After: 42
This tells the client to wait approximately 42 seconds before trying again.
Without the header:
HTTP/1.1 429 Too Many Requests
the client has to guess.
It might retry immediately.
That makes the situation worse.
Or it might wait for a fixed amount of time that is unnecessarily long.
The Retry-After header gives the server a way to communicate its expected retry timing.
What Does Retry-After Mean?
The HTTP Retry-After header can communicate how long a client should wait before making another request.
For example:
Retry-After: 30
means approximately:
Wait 30 seconds.
It can also use an HTTP date, but rate-limiting middleware commonly uses a number of seconds.
For a rate-limited API, the relative form is convenient:
Retry-After: 15
The client can then wait before retrying.
The Problem With Incorrect Retry Information
Imagine a fixed-window limiter:
Window:
10:00:00 → 10:01:00
The client makes its final allowed request at:
10:00:50
The next request is rejected.
At this point, the client only needs to wait until:
10:01:00
So the correct retry delay is roughly:
10 seconds
If the server incorrectly calculates the delay from some other point, it could tell the client:
Retry-After: 60
The client would wait an unnecessary 50 seconds.
That is not just a cosmetic problem.
For high-volume applications, inaccurate retry information can reduce throughput significantly.
What Changed in ASP.NET Core 11?
ASP.NET Core 11 improves the FixedWindowRateLimiter behavior.
The limiter now reports a RetryAfter metadata value that accurately reflects the next window boundary.
That metadata is available through the rate-limit lease.
The application can retrieve it like this:
if (context.Lease.TryGetMetadata(
MetadataName.RetryAfter,
out var retryAfter))
{
// retryAfter contains the suggested delay
}
Then the value can be copied into the HTTP response:
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString(
CultureInfo.InvariantCulture);
This is the important connection:
Rate limiter
|
v
RetryAfter metadata
|
v
HTTP Retry-After header
|
v
Client knows when to retry
Configuring the Rate Limiter
Start with a normal ASP.NET Core application:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(
"api",
limiterOptions =>
{
limiterOptions.PermitLimit = 10;
limiterOptions.Window =
TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/products", () =>
{
return Results.Ok("Products");
})
.RequireRateLimiting("api");
app.Run();
This creates a fixed-window limiter.
The application allows:
10 requests
during:
1 minute
Requests beyond the limit are rejected.
Adding an OnRejected Callback
To customize the 429 response, configure OnRejected:
builder.Services.AddRateLimiter(options =>
{
options.OnRejected = async (
context,
cancellationToken) =>
{
context.HttpContext.Response.StatusCode =
StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsync(
"Too many requests.",
cancellationToken);
};
});
This works, but the response does not yet tell the client when to retry.
We can improve it by reading the limiter metadata.
Returning Retry-After
A better configuration is:
using System.Globalization;
using System.Threading.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
options.OnRejected = async (
context,
cancellationToken) =>
{
if (context.Lease.TryGetMetadata(
MetadataName.RetryAfter,
out var retryAfter))
{
context.HttpContext.Response
.Headers
.RetryAfter =
((int)retryAfter.TotalSeconds)
.ToString(
CultureInfo.InvariantCulture);
}
context.HttpContext.Response.StatusCode =
StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsync(
"Too many requests. Please try again later.",
cancellationToken);
};
});
This is the pattern that makes the .NET 11 improvement useful.
The rate limiter calculates the retry interval.
The application exposes it through the standard HTTP header.
Why Use MetadataName.RetryAfter?
The rate limiter returns information through RateLimitLease metadata.
Instead of manually calculating the retry time, use:
context.Lease.TryGetMetadata(
MetadataName.RetryAfter,
out var retryAfter)
This is preferable because the limiter knows its own scheduling behavior.
For a fixed-window limiter, the next available window can be calculated from the limiter's actual state.
Your application does not need to duplicate that calculation.
That means less custom logic and fewer opportunities for timing bugs.
A Complete Example
Here is a small Minimal API example:
using System.Globalization;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(
"products",
limiterOptions =>
{
limiterOptions.PermitLimit = 5;
limiterOptions.Window =
TimeSpan.FromSeconds(30);
limiterOptions.QueueLimit = 0;
});
options.OnRejected = async (
context,
cancellationToken) =>
{
if (context.Lease.TryGetMetadata(
MetadataName.RetryAfter,
out var retryAfter))
{
context.HttpContext.Response
.Headers
.RetryAfter =
((int)retryAfter.TotalSeconds)
.ToString(
CultureInfo.InvariantCulture);
}
context.HttpContext.Response.StatusCode =
StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsync(
"Rate limit exceeded.",
cancellationToken);
};
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/products", () =>
{
return Results.Ok(new[]
{
"Laptop",
"Monitor",
"Keyboard"
});
})
.RequireRateLimiting("products");
app.Run();
The client can receive something similar to:
HTTP/1.1 429 Too Many Requests
Retry-After: 17
The exact value depends on when the request is rejected relative to the next fixed-window boundary.
Why Hard-Coding Retry-After Is a Bad Idea
You may see examples like:
context.HttpContext.Response.Headers["Retry-After"] = "60";
This is easy to understand, but it is not always correct.
Suppose your window is:
60 seconds
A request could be rejected immediately after the window starts or near the end of the window.
If you always return:
Retry-After: 60
then the client may wait too long.
For example:
Window starts
|
| 50 seconds
v
Request rejected
|
v
Only 10 seconds until next window
Returning:
Retry-After: 60
would unnecessarily delay the client.
Using the limiter's metadata is better:
context.Lease.TryGetMetadata(
MetadataName.RetryAfter,
out var retryAfter)
The application uses the value calculated by the limiter instead of inventing its own value.
Fixed Window Rate Limiting
The fixed-window algorithm divides time into intervals.
For example:
10:00:00 ─────────────── 10:01:00
Window 1
10:01:00 ─────────────── 10:02:00
Window 2
Suppose:
PermitLimit = 100
Window = 1 minute
The first 100 requests in the window are accepted.
Additional requests are rejected until the next window begins.
This approach is simple and easy to reason about.
It is also where an accurate Retry-After value is particularly useful.
Retry-After and Different Rate Limiters
Not every rate-limiting algorithm can always provide an exact retry time.
For example, the ASP.NET Core documentation notes that RetryAfter can be used with algorithms such as fixed-window, token-bucket, and sliding-window limiters because those algorithms can estimate when permits will become available. A concurrency limiter does not have the same ability to predict when a permit will be released.
That distinction matters.
Do not assume:
MetadataName.RetryAfter
will always be available for every possible limiter configuration.
Always check:
if (context.Lease.TryGetMetadata(
MetadataName.RetryAfter,
out var retryAfter))
{
// Use retryAfter
}
rather than assuming the metadata exists.
Retry-After Is a Hint, Not a Guarantee
It is tempting to treat:
Retry-After: 10
Join the conversation! Your thoughts help the community grow.