API rate limits help prevent excessive requests, protect backend services, and keep applications responsive during periods of high traffic. They are commonly used in authentication systems, payment APIs, public APIs, and applications that depend on third-party services.
When a client sends too many requests, an API may return an HTTP 429 Too Many Requests response. The response can also include a Retry-After header that tells the client how long to wait before trying again.
For example, an API might allow five requests within a configured time window. If a client sends a sixth request before the window resets, the server may reject it. The client should then follow the API's retry instructions instead of sending requests repeatedly.
However, implementing rate limiting is only part of the work. Developers also need to verify that the limit is enforced correctly, rejected requests contain the expected headers, and clients handle those responses properly.
This article explains how to configure rate limiting in ASP.NET Core, write automated tests with xUnit, validate Retry-After headers, and test retry behavior without waiting for long production time windows.
Why Rate Limits Need Automated Tests
A rate limiter may work correctly during local development but behave differently after deployment. The difference can come from multiple application instances, API gateways, authentication configuration, or changes to the rate-limiting policy.
Automated tests help verify the expected behavior whenever the application changes.
A useful test suite should check that requests below the limit succeed, requests exceeding the limit receive the expected response, and rejected requests include the headers required by the API contract.
It should also verify that different clients receive independent limits when the policy is designed that way.
For example, if the limit applies separately to each authenticated user, one user's requests should not consume another user's quota. If the limit applies to an entire API key, all requests using that key should share the same limit.
The tests should reflect the actual policy. A per-user limit, a per-IP limit, and a global service limit are different requirements and should not be treated as interchangeable.
Understand HTTP 429 and Retry-After
HTTP 429 indicates that the client has sent too many requests within a given period.
The server may include a Retry-After header to tell the client when it can retry.
A response might look like this:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Please try again later."
}
In this example, the client should wait 30 seconds before attempting another request.
The header can also contain an HTTP date:
HTTP/1.1 429 Too Many Requests
Retry-After: Wed, 21 Oct 2026 10:00:00 GMT
When the header contains a date, the client calculates the delay between that date and the current time.
The Retry-After header is not guaranteed to appear in every 429 response. Your tests should verify its presence when your API contract requires it.
Also, receiving a 429 response does not mean every request should automatically be repeated. Retrying an operation that creates an order or processes a payment can cause duplicate side effects unless the API supports idempotency or another mechanism to make retries safe.
Configure Rate Limiting in ASP.NET Core
ASP.NET Core provides rate-limiting middleware that can restrict how many requests a client may send during a specified period.
The following example creates a fixed-window policy that allows five requests per minute for each client identifier.
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode =
StatusCodes.Status429TooManyRequests;
options.AddPolicy("ApiPolicy", httpContext =>
{
var clientId =
httpContext.Request.Headers["X-Client-Id"]
.ToString();
if (string.IsNullOrWhiteSpace(clientId))
{
clientId = "anonymous";
}
return RateLimitPartition.GetFixedWindowLimiter(
clientId,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
});
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/api/data", () => Results.Ok(new
{
message = "Request accepted"
}))
.RequireRateLimiting("ApiPolicy");
app.Run();
public partial class Program;
The policy creates a separate partition for each client identifier. A client can make five permitted requests during the fixed window. Additional requests are rejected until capacity becomes available again.
The example uses an X-Client-Id header to make testing straightforward. In a production application, an arbitrary client-supplied header is not a reliable security boundary because a caller can change its value.
Use an authenticated user identifier, a validated API key, or another identifier that matches your actual rate-limiting requirements.
The example also places clients without an identifier into a shared anonymous partition. That may be suitable for some applications, but it can cause unrelated callers to consume the same quota. Choose the partitioning strategy deliberately.
Return Retry-After When a Request Is Rejected
Setting the rejection status code does not automatically guarantee that every rejected response contains the Retry-After header.
If your API requires the header, configure a rejection handler.
The following example adds a 60-second delay to rejected responses:
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode =
StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.Headers.RetryAfter = "60";
context.HttpContext.Response.ContentType =
"application/json";
await context.HttpContext.Response.WriteAsJsonAsync(
new
{
error = "rate_limit_exceeded",
message = "Please wait before retrying."
},
cancellationToken);
};
// Register the ApiPolicy here.
});
This is the rejection-handler portion of the configuration. If you combine it with the previous example, keep a single AddRateLimiter registration and place the policy and handler inside it.
A fixed delay is easy to understand, but it may not represent the actual time remaining in the current window. In production, calculate the delay according to the rate-limiting policy when possible.
For automated testing, the important requirement is consistency between the server's behavior and the documented API contract.
Write an Integration Test with xUnit
Integration tests can send real HTTP requests to the ASP.NET Core application and verify the responses produced by its middleware.
For an ASP.NET Core integration test project, install the required packages if they are not already available:
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
The following test uses WebApplicationFactory to start the application in memory.
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public class RateLimitTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public RateLimitTests(
WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task RequestsBeyondLimitReturnTooManyRequests()
{
var clientId = Guid.NewGuid().ToString("N");
for (var i = 0; i < 5; i++)
{
using var response = await SendRequest(clientId);
Assert.Equal(
HttpStatusCode.OK,
response.StatusCode);
}
using var rejectedResponse =
await SendRequest(clientId);
Assert.Equal(
HttpStatusCode.TooManyRequests,
rejectedResponse.StatusCode);
}
private Task<HttpResponseMessage> SendRequest(
string clientId)
{
var request = new HttpRequestMessage(
HttpMethod.Get,
"/api/data");
request.Headers.Add("X-Client-Id", clientId);
return _client.SendAsync(request);
}
}
The test sends five requests using the same client identifier and expects them to succeed. It then sends a sixth request and checks that the server returns HTTP 429.
A unique identifier helps isolate this test from other tests that may use the same application host.
Make sure the test host uses the intended rate-limiting configuration. If the test environment disables the middleware or replaces the policy, the test may pass without verifying the behavior used in production.
For reliable results, avoid running multiple tests against the same rate-limit partition unless sharing that state is intentional.
Verify the Retry-After Header
The next test checks that the rejected response contains the expected header.
[Fact]
public async Task RejectedRequestIncludesRetryAfterHeader()
{
var clientId = Guid.NewGuid().ToString("N");
for (var i = 0; i < 5; i++)
{
using var response = await SendRequest(clientId);
Assert.Equal(
HttpStatusCode.OK,
response.StatusCode);
}
using var rejectedResponse =
await SendRequest(clientId);
Assert.Equal(
HttpStatusCode.TooManyRequests,
rejectedResponse.StatusCode);
Assert.True(
rejectedResponse.Headers.TryGetValues(
"Retry-After",
out var values));
Assert.Contains("60", values);
}
This test checks the fixed value configured in the rejection handler.
If the API calculates a different delay for each response, avoid asserting that the header must always equal 60. Instead, validate its format and confirm that the value follows the documented policy.
For a numeric Retry-After value, you can verify that the response contains a non-negative integer:
using System.Globalization;
Assert.True(
rejectedResponse.Headers.TryGetValues(
"Retry-After",
out var values));
var retryAfterValue = values.First();
Assert.True(
int.TryParse(
retryAfterValue,
NumberStyles.None,
CultureInfo.InvariantCulture,
out var retrySeconds));
Assert.True(retrySeconds >= 0);
This example checks the numeric form. If your API returns an HTTP date, parse it as a date and verify that it represents a valid retry time.
Avoid assertions that depend on an exact timestamp unless the test controls the clock. Small differences in execution time can otherwise make a correct implementation appear to fail.
Check Whether Different Clients Have Independent Limits
When the policy uses a separate partition for each client, requests from one client should not consume another client's quota.
The following test checks that behavior:
[Fact]
public async Task DifferentClientsHaveIndependentLimits()
{
var firstClient = Guid.NewGuid().ToString("N");
var secondClient = Guid.NewGuid().ToString("N");
for (var i = 0; i < 5; i++)
{
using var response =
await SendRequest(firstClient);
Assert.Equal(
HttpStatusCode.OK,
response.StatusCode);
}
using var rejectedResponse =
await SendRequest(firstClient);
Assert.Equal(
HttpStatusCode.TooManyRequests,
rejectedResponse.StatusCode);
using var secondResponse =
await SendRequest(secondClient);
Assert.Equal(
HttpStatusCode.OK,
secondResponse.StatusCode);
}
This test verifies that the application creates separate partitions for the two identifiers.
However, it only tests the behavior of the application instance running in the test host. An in-memory limiter does not automatically share its counters across multiple application replicas.
If your API runs across several instances and requires a global limit, you need an architecture that supports distributed enforcement or a suitable API gateway policy. Test that behavior in an environment representative of the deployed system.
Test Client Retry Behavior Without Waiting for a Real API
Testing the server's rate limiter and testing a client's retry logic are two separate tasks.
An integration test verifies that the real server enforces the configured limit. A client unit test can simulate a 429 response and check that the client waits before retrying.
A custom HTTP handler makes it possible to simulate a rate-limited response followed by a successful response.
using System.Net;
using System.Net.Http.Headers;
public sealed class RetryAfterHandler : HttpMessageHandler
{
private int _requestCount;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
_requestCount++;
if (_requestCount == 1)
{
var response = new HttpResponseMessage(
HttpStatusCode.TooManyRequests);
response.Headers.RetryAfter =
new RetryConditionHeaderValue(
TimeSpan.FromSeconds(2));
return Task.FromResult(response);
}
return Task.FromResult(
new HttpResponseMessage(HttpStatusCode.OK));
}
}
This handler simulates the responses a client receives. It does not enforce a real rate limit.
You can inject it into an HttpClient and use it to test the retry service. For fast and predictable tests, provide a delay abstraction that records the requested wait rather than actually sleeping for two seconds.
The test can then verify that the client requests the expected delay and does not retry before that delay has elapsed.
This approach is useful when the retry policy includes several rules, such as a maximum attempt count, exponential backoff, cancellation support, or different behavior for different HTTP status codes.
Parse Retry-After Correctly
The Retry-After header can contain a delay in seconds or an HTTP date. A client should handle the forms supported by the API rather than assuming that the header always contains an integer.
The following helper uses the .NET RetryConditionHeaderValue type to parse the value:
using System.Net.Http.Headers;
public static class RetryAfterParser
{
public static TimeSpan? GetDelay(
RetryConditionHeaderValue? retryAfter,
DateTimeOffset now)
{
if (retryAfter is null)
{
return null;
}
if (retryAfter.Delta is TimeSpan delta)
{
return delta < TimeSpan.Zero
? TimeSpan.Zero
: delta;
}
if (retryAfter.Date is DateTimeOffset retryDate)
{
var delay = retryDate - now;
return delay < TimeSpan.Zero
? TimeSpan.Zero
: delay;
}
return null;
}
}
The helper returns null when the header is missing. When the header contains a past date or a negative delay, it returns zero rather than a negative wait.
A production retry policy should also set a maximum acceptable delay and a maximum number of attempts. If the header is missing or invalid, use a documented fallback policy rather than retrying immediately in a loop.
For transient failures, exponential backoff with jitter can reduce synchronized retries from many clients. When a valid Retry-After value is present, respect the server's requested delay within the application's retry limits.
Before retrying operations that create or modify data, confirm that repeating the operation is safe. Idempotency keys can help prevent duplicate side effects when supported by the API.
Avoid Common Testing Mistakes
Testing rate limiting requires attention to both application behavior and test isolation.
Using a different client identifier for every request will not test whether one client exceeds its limit. Conversely, sharing a partition across unrelated tests can cause unexpected failures.
Testing only the HTTP status code is also insufficient when the API promises specific headers or error response fields.
Another common mistake is assuming that an in-memory rate limiter provides a global limit across multiple application instances. Distributed applications need a rate-limiting design that accounts for how requests are spread across replicas.
Finally, avoid tests that wait for long production windows to expire. Use short test-specific windows where appropriate, or test retry behavior with a controlled HTTP handler and a fake delay mechanism.
Conclusion
Automated tests help verify that API rate limits behave as expected and that clients respond correctly when the server rejects excessive traffic.
Use integration tests to check request limits, HTTP 429 responses, Retry-After headers, and client partitioning. Use unit tests with simulated responses to verify retry delays without depending on real network calls or long waiting periods.
For production systems, also consider multiple application instances, gateway-level limits, safe retries, and the API's documented behavior. Testing these details helps prevent unexpected throttling and reduces the risk of retry storms during periods of high traffic.
Join the conversation! Your thoughts help the community grow.