Skip to main content

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.

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.

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:
  1. Client A reads the counter: GET rate_limit:user:123:/api/data9
  2. Client B reads the counter: GET rate_limit:user:123:/api/data9
  3. Client A decides 9 < 10 → allowed; increments: INCR10
  4. Client B decides 9 < 10 → allowed; increments: INCR11
Both clients passed the check against a stale value. With a Lua script, steps 1–4 happen inside a single atomic block. Client B’s read cannot occur until Client A’s entire script — including the increment — has finished. The counter Client B reads is always the post-increment value of 10, and it is correctly blocked.

No Transactions Required

Redis also supports MULTI/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 inside RedisRateLimiter.cs. Here is the exact script as it appears in the source:
local current = redis.call('INCR', @key)
if tonumber(current) == 1 then
    redis.call('EXPIRE', @key, @window)
end
if tonumber(current) > tonumber(@maxRequests) then
    return 0
else
    return 1
end

Line-by-Line Annotation

-- 1. Atomically increment the counter for this key.
--    If the key does not yet exist, Redis creates it at 0 and then increments
--    to 1. The new value is stored in `current`.
local current = redis.call('INCR', @key)

-- 2. If this is the very first request in a new window (counter just became 1),
--    set the key's TTL to the configured window length in seconds.
--    This is done ONLY on first creation so subsequent increments within the
--    same window never accidentally reset or extend the expiry.
if tonumber(current) == 1 then
    redis.call('EXPIRE', @key, @window)
end

-- 3. Compare the current count against the configured maximum.
--    If the limit has been exceeded, return 0 (blocked).
--    Otherwise, return 1 (allowed).
if tonumber(current) > tonumber(@maxRequests) then
    return 0
else
    return 1
end
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 in RedisRateLimiter.IsAllowedAsync:
public virtual async Task<bool> IsAllowedAsync(string key, int maxRequests, TimeSpan window)
{
    var script = LuaScript.Prepare(@"
        local current = redis.call('INCR', @key)
        if tonumber(current) == 1 then
            redis.call('EXPIRE', @key, @window)
        end
        if tonumber(current) > tonumber(@maxRequests) then
            return 0
        else
            return 1
        end
    ");

    var result = await _db.ScriptEvaluateAsync(script, new
    {
        key = (RedisKey)$"rate_limit:{key}",
        window = (int)window.TotalSeconds,
        maxRequests = maxRequests
    });

    return (int)result == 1;
}

Key Points

ElementWhat 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.TotalSecondsRedis EXPIRE accepts integer seconds. TimeSpan.TotalSeconds is cast to int before passing.
(int)result == 1Converts 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 ValueMeaningC# Result
1The request is within the limit and is allowed to proceed.true
0The request has exceeded the limit and should be blocked.false
When 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:
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.Headers.RetryAfter = rateLimitAttr.WindowSeconds.ToString();
The request pipeline is short-circuited — _next is never called, and no downstream handlers execute.

Build docs developers (and LLMs) love