Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions content/develop/use-cases/rate-limiter/dotnet/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,243 @@ catch (RedisException ex)
}
```

## Alternative rate limiting algorithms

The token bucket algorithm above handles most use cases but Redis supports
other rate limiter patterns that might fit your requirements better. The table
below lists four other algorithms alongside token bucket and summarizes
their features:

| Algorithm | Memory | Accuracy | Burst behavior | Best for |
|---|---|---|---|---|
| [Token bucket](#how-it-works) | 1 key (hash) | Exact | Controlled bursts | APIs with bursty traffic |
| [Fixed window counter](#fixed-window-counter) | 1 key (string) | Approximate | 2x burst at boundaries | Simple API limits |
| [Sliding window log](#sliding-window-log) | O(n) entries | Exact | No bursts | High-value APIs, audit trails |
| [Sliding window counter](#sliding-window-counter) | 2 keys (string) | Near-exact | Smoothed boundaries | General-purpose APIs |
| [Leaky bucket (policing)](#leaky-bucket-policing) | 1 key (hash) | Exact | No bursts | Strict no-burst enforcement |

The sections below give example implementations of these other algorithms.
The three time-based algorithms call `redis.call('TIME')` inside the Lua
script to derive the current timestamp from the Redis server clock. This
eliminates clock drift when the limiter runs across multiple application
servers. The fixed window counter reads no clock: the key's TTL defines
the window.

### Fixed window counter

Counts requests within discrete, non-overlapping time intervals.
Simplest algorithm — one key per window, one `EVAL` round trip.

```csharp
using StackExchange.Redis;

const string FixedWindowScript = @"
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, window)
end

local ttl = redis.call('PTTL', key)

if count > limit then
return {0, ttl}
end
return {1, ttl}
";

// Returns (allowed, retryAfterMs). retryAfterMs is 0 when the request is allowed.
static (bool Allowed, long RetryAfterMs) FixedWindowAllow(
IDatabase db, string key, int limit, int windowSeconds)
{
var result = (RedisResult[])db.ScriptEvaluate(
FixedWindowScript,
new RedisKey[] { key },
new RedisValue[] { limit, windowSeconds });

var allowed = (long)result[0] == 1;
return (allowed, allowed ? 0 : (long)result[1]);
}
```

**Trade-off**: A client can make 2x requests by sending `limit` requests
at the end of one window and `limit` requests at the start of the next.

### Sliding window log

Records the exact timestamp of every request in a sorted set.
Provides a true rolling window with no boundary bursts.

```csharp
using StackExchange.Redis;

const string SlidingWindowLogScript = @"
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local member = ARGV[3]

local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local cutoff = now - window

redis.call('ZREMRANGEBYSCORE', key, '-inf', cutoff)

local count = redis.call('ZCARD', key)

if count < limit then
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window * 2)
return {1, 0}
end

local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after_ms = 0
if oldest[2] then
retry_after_ms = math.floor((tonumber(oldest[2]) + window - now) * 1000)
end

return {0, retry_after_ms}
";

static (bool Allowed, long RetryAfterMs) SlidingWindowLogAllow(
IDatabase db, string key, int limit, int windowSeconds)
{
var member = Guid.NewGuid().ToString();

var result = (RedisResult[])db.ScriptEvaluate(
SlidingWindowLogScript,
new RedisKey[] { key },
new RedisValue[] { limit, windowSeconds, member });

return ((long)result[0] == 1, (long)result[1]);
}
```

**Trade-off**: Memory grows O(n) with request volume. Not ideal for
high-volume, high-cardinality rate limiting.

### Sliding window counter

Blends two fixed-window counters using a weighted average to approximate
a true sliding window. Near-exact accuracy with the same low memory
footprint as a fixed window. The two keys use hash tags so they map
to the same slot in Redis Cluster.

```csharp
using StackExchange.Redis;

const string SlidingWindowCounterScript = @"
local base = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6

local window_num = math.floor(now / window)
local elapsed = (now % window) / window

local curr_key = base .. ':' .. window_num
local prev_key = base .. ':' .. (window_num - 1)

local prev = tonumber(redis.call('GET', prev_key) or 0)
local curr = tonumber(redis.call('GET', curr_key) or 0)

local estimate = prev * (1 - elapsed) + curr

if estimate >= limit then
return {0, 0}
end

local new_count = redis.call('INCR', curr_key)
if new_count == 1 then
redis.call('EXPIRE', curr_key, window * 2)
end

return {1, 0}
";

static bool SlidingWindowCounterAllow(
IDatabase db, string key, int limit, int windowSeconds)
{
var result = (RedisResult[])db.ScriptEvaluate(
SlidingWindowCounterScript,
new RedisKey[] { $"{{{key}}}" },
new RedisValue[] { limit, windowSeconds });

return (long)result[0] == 1;
}
```

**Trade-off**: The weighted estimate may let slightly more or fewer
requests through than the exact limit. Negligible for most apps.

### Leaky bucket (policing)

A virtual bucket fills with incoming requests and drains at a fixed rate.
If the bucket is full, requests are rejected immediately. This is the
policing variant — requests are allowed or denied instantly with no delay.

```csharp
using StackExchange.Redis;

const string LeakyBucketScript = @"
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local leak_rate = tonumber(ARGV[2])

local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6

local data = redis.call('HGETALL', key)
local level = 0
local last_leak = now

if #data > 0 then
for i = 1, #data, 2 do
if data[i] == 'level' then
level = tonumber(data[i+1])
elseif data[i] == 'last_leak' then
last_leak = tonumber(data[i+1])
end
end
end

local elapsed = now - last_leak
level = math.max(0, level - elapsed * leak_rate)

if level + 1 > capacity then
return {0, math.floor((level + 1 - capacity) / leak_rate * 1000)}
end

level = level + 1
local ttl = math.ceil(capacity / leak_rate) + 1

redis.call('HSET', key, 'level', level, 'last_leak', now)
redis.call('EXPIRE', key, ttl)

return {1, 0}
";

static (bool Allowed, long RetryAfterMs) LeakyBucketAllow(
IDatabase db, string key, int capacity, double leakRate)
{
var result = (RedisResult[])db.ScriptEvaluate(
LeakyBucketScript,
new RedisKey[] { key },
new RedisValue[] { capacity, leakRate });

return ((long)result[0] == 1, (long)result[1]);
}
```

**Trade-off**: Overflow traffic is rejected immediately. Clients must
handle `429 Too Many Requests` and retry with backoff.

## Learn more

* [EVAL command]({{< relref "/commands/eval" >}}) - Execute Lua scripts
Expand Down
Loading
Loading