A distributed rate limiter for ASP.NET Core backed by Redis. It implements the four classic algorithms (fixed window, sliding window log, sliding window counter and token bucket) as atomic Lua scripts, so limits hold across any number of API instances with no race conditions and no distributed locks.
builder.Services.AddDistributedRateLimiting(
builder.Configuration.GetSection("RateLimiting"),
builder.Configuration.GetConnectionString("Redis")!);
app.UseDistributedRateLimiting();
app.MapPost("/api/login", Login).RequireDistributedRateLimit("login");docker compose up --build # Redis + two API instances on :8081 and :8082ASP.NET Core's built-in rate limiter keeps counters in process memory. With 3 instances behind a load balancer, a "100 requests/minute" limit silently becomes 300. This library stores state in Redis, so every instance enforces one shared quota.
flowchart LR
C[Client<br/>X-Api-Key: abc] --> LB[Load balancer]
LB --> A1[API instance 1]
LB --> A2[API instance 2]
LB --> A3[API instance N]
A1 -- "EVALSHA (atomic)" --> R[("Redis<br/>rl:{policy:client-hash}")]
A2 -- "EVALSHA (atomic)" --> R
A3 -- "EVALSHA (atomic)" --> R
sequenceDiagram
participant Client
participant Middleware as Rate limit middleware
participant Redis
participant Endpoint
Client->>Middleware: GET /api/orders (X-Api-Key)
Middleware->>Middleware: Resolve policy from endpoint metadata + client key
Middleware->>Redis: Lua script (read → decide → write, atomic)
Redis-->>Middleware: {allowed, remaining, retryAfterMs}
alt allowed
Middleware->>Endpoint: next()
Endpoint-->>Client: 200 + RateLimit-* headers
else rejected
Middleware-->>Client: 429 + Retry-After + RateLimit-* headers
end
All four are in LuaScripts.cs.
| Algorithm | How it works | Accuracy | Memory per client | Best for |
|---|---|---|---|---|
| Fixed window | INCR a counter keyed by window start |
Allows up to 2× burst at window edges | O(1) | Cheap coarse limits (daily quotas) |
| Sliding window log | Sorted set of request timestamps; trim, count, add | Exact | O(limit) | Strict limits with small quotas (login, OTP) |
| Sliding window counter | Previous window count × overlap weight + current count | Near-exact (assumes even distribution) | O(1) | High-volume API limits |
| Token bucket | Tokens refill continuously up to capacity | Exact average rate, controlled bursts | O(1) | APIs that should tolerate short bursts |
| Decision | Why |
|---|---|
| One Lua script per check | Read-check-write is atomic inside Redis. Two instances can never both read "99" and both allow request 100. No WATCH/MULTI retries or locks |
Redis server time (TIME) |
All instances share one clock, so app-server clock skew can't widen or shrink windows |
Hash tag keys rl:{policy:client} |
Every key a script derives stays in one Redis Cluster slot, as cluster mode requires |
| Client keys hashed (SHA-256) | Raw API keys and IPs are never stored in Redis; key length stays bounded |
| Explicit fail-open / fail-closed | Redis is now on the request path. Fail open (default) keeps the API up during an outage; fail closed protects fragile downstream systems. Degraded responses are flagged with RateLimit-Degraded: true |
| Short Redis timeouts (1s) | A slow Redis must not stall every request; the degraded path takes over |
Precise Retry-After |
Each script computes the earliest moment a retry can succeed (e.g. when the oldest log entry expires) instead of returning the full window |
| Standard headers | RateLimit-Limit, RateLimit-Remaining, RateLimit-Policy (IETF draft) and Retry-After, so clients can back off before getting rejected |
| Metrics | System.Diagnostics.Metrics counter ratelimit.decisions tagged by policy and result (allowed / rejected / degraded), ready for OpenTelemetry |
"RateLimiting": {
"FailOpen": true,
"Policies": [
{ "Name": "fixed-window", "Algorithm": "FixedWindow", "PermitLimit": 10, "Window": "00:00:10" },
{ "Name": "sliding-log", "Algorithm": "SlidingWindowLog", "PermitLimit": 10, "Window": "00:01:00" },
{ "Name": "sliding-counter", "Algorithm": "SlidingWindowCounter", "PermitLimit": 20, "Window": "00:00:30" },
{ "Name": "token-bucket", "Algorithm": "TokenBucket", "PermitLimit": 10, "Window": "00:00:10" }
]
}PermitLimitmeans requests per window. For a token bucket it's the capacity (max burst).Windowis the window length. For a token bucket it's the time to refill a full bucket.- Policies are validated at startup (
ValidateOnStart). - The client identity defaults to the
X-Api-Keyheader, falling back to the client IP. Override it withoptions.ClientKeyResolver(e.g. user id from JWT claims).
docker compose up --buildSend 12 requests alternating between the two instances. The shared quota allows exactly 10:
for i in $(seq 1 12); do
port=$(( i % 2 == 0 ? 8081 : 8082 ))
curl -s -o /dev/null -w "instance :$port -> %{http_code}\n" -H "X-Api-Key: demo" http://localhost:$port/api/sliding-log
doneInspect the headers:
curl -i -H "X-Api-Key: demo" http://localhost:8081/api/token-bucket
# RateLimit-Limit: 10
# RateLimit-Remaining: 9
# RateLimit-Policy: 10;w=10dotnet test # unit tests; Redis tests auto-skip without Redis
docker run -d -p 6379:6379 redis:7-alpine && dotnet test # full suite| Suite | What it proves |
|---|---|
| Algorithm tests (real Redis) | Each algorithm allows exactly the limit, reports Remaining correctly, isolates clients, and allows again after Retry-After |
| Concurrency test (real Redis) | 1,000 concurrent requests across 4 independent connections (simulated instances) → exactly 100 allowed, for every algorithm |
| Failure-mode tests | Unreachable Redis → fail-open allows (degraded), fail-closed rejects |
| Middleware tests (TestServer) | Headers, 429 body, Retry-After rounding, API-key identity, unprotected endpoints bypass Redis |
| Distributed end-to-end (CI, Docker) | Two real API instances + Redis: a 30-request burst split across both gets exactly 10 × 200 and 20 × 429; separate clients have separate quotas; stopping Redis triggers fail-open with RateLimit-Degraded: true |
In CI, REQUIRE_REDIS=true makes the Redis suites fail instead of skip, so they always run.
src/DistributedRateLimiting/ The library
LuaScripts.cs Four algorithms as atomic Redis Lua scripts
RedisRateLimiter.cs Script execution, failure policy, metrics
DistributedRateLimitingMiddleware.cs Policy lookup, headers, 429 responses
RedisKeys.cs Hash-tagged, hashed key builder
ServiceCollectionExtensions.cs AddDistributedRateLimiting / RequireDistributedRateLimit
samples/RateLimiting.DemoApi/ Demo API with one endpoint per algorithm
tests/DistributedRateLimiting.Tests/ Unit, middleware, failure-mode and Redis integration tests
docker-compose.yml Redis + two API instances
- BenchmarkDotNet latency and throughput numbers per algorithm
- Local in-memory pre-check to cut Redis round-trips for clearly over-limit clients
- Per-endpoint cost (weighted requests)
- NuGet package