Skip to content

Repository files navigation

Distributed Rate Limiter: .NET 8 + Redis

ci .NET 8 Redis License: MIT

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 :8082

Why distributed?

ASP.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
Loading

Request flow

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
Loading

Algorithms

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

Design decisions

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

Configuration

"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" }
  ]
}
  • PermitLimit means requests per window. For a token bucket it's the capacity (max burst).
  • Window is 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-Key header, falling back to the client IP. Override it with options.ClientKeyResolver (e.g. user id from JWT claims).

Try it

docker compose up --build

Send 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
done

Inspect 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=10

Testing

dotnet 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.


Project structure

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

Roadmap

  • 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

License

MIT

About

Distributed rate limiter for ASP.NET Core: fixed window, sliding log, sliding counter and token bucket as atomic Redis Lua scripts, shared across API instances

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages