Moving counter state to Redis solves the multi-instance split-brain problem, but introduces a subtler one: what if two API instances call Redis at the exact same time, both read the same counter value, and both decide to allow a request before either has incremented the key? Dotnet-RateLimiter closes this gap by offloading the entire check-and-increment logic to a Lua script executed inside Redis. Because Redis runs Lua atomically, the read, increment, expiry-set, and comparison happen as a single indivisible unit — no other Redis command can interleave, no matter how many instances are hammering the same key simultaneously.Documentation Index
Fetch the complete documentation index at: https://mintlify.com/nimanikoo/Dotnet-RateLimiter/llms.txt
Use this file to discover all available pages before exploring further.
Why Lua?
Redis exposes a scripting engine that executes Lua code server-side, and makes a hard guarantee: a Lua script is always atomic. While the script is running, no other Redis commands — from any connected client — can execute. The entire script completes, start to finish, before Redis picks up the next command.Eliminating TOCTOU
The classic distributed rate-limiting vulnerability is the Time-Of-Check-Time-Of-Use (TOCTOU) race:- Client A reads the counter:
GET rate_limit:user:123:/api/data→9 - Client B reads the counter:
GET rate_limit:user:123:/api/data→9 - Client A decides
9 < 10→ allowed; increments:INCR→10 - Client B decides
9 < 10→ allowed; increments:INCR→11
10, and it is correctly blocked.
No Transactions Required
Redis also supportsMULTI/EXEC transactions and optimistic locking with WATCH, but both approaches require multiple round-trips and are more complex to reason about. A Lua script achieves the same guarantee in a single network call, with lower latency and simpler application code.
The Lua Script
The script at the heart of Dotnet-RateLimiter lives insideRedisRateLimiter.cs. Here is the exact script as it appears in the source:
Line-by-Line Annotation
INCR-then-EXPIRE pattern and TTL safetyThe
EXPIRE call is guarded by if current == 1. This is intentional and critical. If EXPIRE were called on every request, the window would be continuously extended on every hit — a busy endpoint would never reset, and heavy users would be permanently rate-limited. By setting the TTL only when the key is first created, the window is fixed at creation time and expires naturally, after which the key is deleted by Redis and the next request starts a fresh window from 1.There is also a subtle failure edge: if the process crashed between INCR and the EXPIRE call in a naive two-command implementation, the key would persist indefinitely with no TTL. Because both operations happen inside the same Lua script — which is atomic — either both succeed or neither does. A partial execution is not possible.The C# Integration
The script is prepared and executed inRedisRateLimiter.IsAllowedAsync:
Key Points
| Element | What It Does |
|---|---|
LuaScript.Prepare(...) | Compiles the Lua script template using StackExchange.Redis’s named-parameter syntax (@key, @window, @maxRequests). Preparation happens at call time; in production you would cache the prepared script to avoid re-parsing. |
ScriptEvaluateAsync(...) | Sends the script and its arguments to Redis in a single command (EVALSHA after the first call). The script runs server-side; only the return value travels back over the network. |
key = (RedisKey)$"rate_limit:{key}" | Prefixes every key with rate_limit: so rate limiting keys are clearly namespaced in Redis and never collide with application data. The incoming key is already scoped as {userType}:{identityKey}:{path}. |
window = (int)window.TotalSeconds | Redis EXPIRE accepts integer seconds. TimeSpan.TotalSeconds is cast to int before passing. |
(int)result == 1 | Converts the Redis RedisResult to a C# bool. 1 means allowed; 0 means blocked. |
What the Script Returns
The Lua script has exactly two possible return values:| Return Value | Meaning | C# Result |
|---|---|---|
1 | The request is within the limit and is allowed to proceed. | true |
0 | The request has exceeded the limit and should be blocked. | false |
IsAllowedAsync returns false, the middleware responds immediately with HTTP 429 Too Many Requests and sets the Retry-After header to the configured window in seconds:
_next is never called, and no downstream handlers execute.