This post was originally published in Portuguese on my blog: Rate limiting em Minimal APIs com o middleware nativo do .NET.

Every API exposed to the internet will, sooner or later, get a client that decides to hit the same endpoint a thousand times per second. It might be a bug in a retry loop, a poorly written script or someone trying their luck. If you have no limit at all, the database, the CPU and your other users pay the bill.

The good news is that since .NET 7, rate limiting ships with the framework. No extra package, no third-party library. In this post I build a small example with Minimal APIs, per-IP limits, a 429 response with Retry-After and integration tests.

The core idea

The middleware works with two concepts: the limiter (the algorithm: fixed window, sliding window, token bucket or concurrency) and the partition (who is being limited). Without a partition, the limit is global, and one abusive client takes the service down for everyone. Partitioning by IP gives each client its own bucket.

You register a named policy and then apply it to the endpoints that need it. Endpoints like health checks usually stay out, otherwise the orchestrator may decide your application is down.

Registering the policy

builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("por-ip", context => RateLimitPartition.GetFixedWindowLimiter( partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "anonimo", factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = limit, Window = TimeSpan.FromSeconds(windowSeconds), QueueLimit = 0 })); }); 

QueueLimit = 0 makes excess requests get rejected right away instead of waiting in a queue for the next window. For a public API, that's usually what you want. The limit and window values come from configuration, which helps a lot when testing.

Telling the client when to come back

Returning 429 without saying when to try again forces the client to guess, and guessing turns into aggressive retries. The OnRejected callback lets you send back a Retry-After header with the time left in the window:

options.OnRejected = (context, _) => { if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retry)) { context.HttpContext.Response.Headers.RetryAfter = ((int)retry.TotalSeconds).ToString(); } return ValueTask.CompletedTask; }; 

Applying it to the endpoints

var app = builder.Build(); app.UseRateLimiter(); app.MapGet("/", () => Results.Ok(new { mensagem = "ola", hora = DateTime.UtcNow })) .RequireRateLimiting("por-ip"); app.MapGet("/health", () => Results.Ok("ok")); // no limit app.Run(); public partial class Program { } 

A detail that catches a lot of people: UseRateLimiter() has to come before mapping the endpoints that use the policy, and the partial class Program at the end exists only so the test project can see the class.

Testing it for real

With WebApplicationFactory you can spin up the application in memory and override the configuration. I lower the limit to 3 requests and keep the window long, so the test doesn't depend on timing:

[Fact] public async Task Requisicao_acima_do_limite_retorna_429_com_retry_after() { var client = _factory.CreateClient(); for (var i = 0; i < 3; i++) await client.GetAsync("/"); var resp = await client.GetAsync("/"); Assert.Equal(HttpStatusCode.TooManyRequests, resp.StatusCode); Assert.True(resp.Headers.Contains("Retry-After")); } 

There's also a test making sure /health handles ten calls in a row without getting a 429. It's the kind of regression that slips by until the day the load balancer pulls your application out of rotation.

Running with Docker

The project has a multi-stage Dockerfile with two targets, app and tests, and the compose file exposes both:

docker compose up --build # in another terminal, call it more than 5 times within 10 seconds for i in $(seq 1 7); do curl -s -o /dev/null -w "%{http_code} " localhost:8080/; done docker compose run --rm tests 

You should see something like 200 200 200 200 200 429 429. If you look at the headers of the rejected response, Retry-After will be there.

Before going to production

This limiter lives in the memory of each instance. With several replicas behind a load balancer, each one counts on its own, so the effective limit is multiplied by the number of instances. If you need a global, exact limit, the way to go is a shared counter, usually in Redis, or delegating it to the gateway.

Behind a reverse proxy, RemoteIpAddress will be the proxy's IP, not the client's. Configure ForwardedHeaders first, otherwise all users fall into the same partition and share the same limit.

For day-to-day work, the built-in middleware handles most basic protection scenarios well, with no dependencies at all.

The full code is at programero-csharp/2026-11-rate-limiting-minimal-apis.