From 1268f384e48c11ac937c64cb0a048b64c7a70d5c Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 26 Aug 2026 12:50:34 +0100 Subject: [PATCH 1/2] DOC-6999: port alternative rate limiter algorithms to eight client pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds fixed window counter, sliding window log, sliding window counter and leaky bucket to the dotnet, go, java-jedis, java-lettuce, nodejs, php, ruby and rust rate limiter pages, following the community contribution in #3788 that added them to redis-py only. Lettuce gets sync only, matching its existing token bucket examples, and everything is snippets — no new runnable source files, so these four algorithms stay unbacked by demo code the way redis-py left them. The four Lua scripts are the algorithm; only the wrapper is per-language. So the sections were generated from a script that extracts the Lua and the prose once from redis-py and reuses both, rather than being written eight times by hand. That is the only reason nine copies can be trusted to stay in step, and it is why the identity check below is worth re-running after any edit here. Two things that cost time and will cost it again. First, the obvious way to extract the Lua back out for comparison — take lines from the first "local" to the last "return {" — silently over-captures, because the wrapper code also contains a return of a brace-delimited value. It reported four false mismatches before the bound was changed to the host string-literal terminator. Second, compiling the dotnet snippets reported Guid as unresolved, which looks like a defect in the snippet but is the harness missing ImplicitUsings; these pages already assume it, since the existing examples call Console.WriteLine with no System import. A check project has to match what "dotnet new console" gives the reader or it manufactures errors. Also corrects a claim on the redis-py page before copying it eight times over: it said all four algorithms read the server clock via TIME, but the fixed window counter reads no clock at all and leans on the key's TTL. Verified by compiling against jedis 7.5.3, lettuce-core 7.7.0, StackExchange.Redis 2.9.32 and redis 0.24.1, parsing the rest, running the four scripts and the full Ruby wrappers against a live server, and a clean Hugo build with every comparison-table anchor resolving. Learned: an extractor bounded by a code pattern the wrapper also contains over-captures, and a compile harness that misses the reader's project defaults invents failures Constraint: the four Lua scripts must stay byte-identical across all nine rate-limiter pages — that is what makes the reuse safe Directive: never hand-edit the Lua on one page; change all nine together and re-run the identity check bounded by the string-literal terminator, not by "return {" Rejected: uuid-free sorted-set members for rust from pid plus a counter | pid collisions across hosts silently overwrite log entries and undercount, so the uuid crate is worth the dependency Rejected: inserting the section after Response headers to mirror redis-py's offset | splits the token bucket material, because each page's Customization section refers back to the token bucket limiter Gaps: only the Ruby wrappers were run end to end; the other seven were compiled or parsed, so per-language argument marshalling and return unpacking are unproven Ticket: DOC-6999 Co-Authored-By: Claude Opus 5 (1M context) --- .../use-cases/rate-limiter/dotnet/_index.md | 237 +++++++++++++++ .../use-cases/rate-limiter/go/_index.md | 270 +++++++++++++++++ .../rate-limiter/java-jedis/_index.md | 271 ++++++++++++++++++ .../rate-limiter/java-lettuce/_index.md | 267 +++++++++++++++++ .../use-cases/rate-limiter/nodejs/_index.md | 223 ++++++++++++++ .../use-cases/rate-limiter/php/_index.md | 239 +++++++++++++++ .../use-cases/rate-limiter/redis-py/_index.md | 8 +- .../use-cases/rate-limiter/ruby/_index.md | 220 ++++++++++++++ .../use-cases/rate-limiter/rust/_index.md | 255 ++++++++++++++++ 9 files changed, 1987 insertions(+), 3 deletions(-) diff --git a/content/develop/use-cases/rate-limiter/dotnet/_index.md b/content/develop/use-cases/rate-limiter/dotnet/_index.md index 34b3715304..1cf9861265 100644 --- a/content/develop/use-cases/rate-limiter/dotnet/_index.md +++ b/content/develop/use-cases/rate-limiter/dotnet/_index.md @@ -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 diff --git a/content/develop/use-cases/rate-limiter/go/_index.md b/content/develop/use-cases/rate-limiter/go/_index.md index 9bf99a694d..37fea43d43 100644 --- a/content/develop/use-cases/rate-limiter/go/_index.md +++ b/content/develop/use-cases/rate-limiter/go/_index.md @@ -386,6 +386,276 @@ if err != nil { } ``` +## 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. + +```go +import ( + "context" + + "github.com/redis/go-redis/v9" +) + +const 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} +` + +// FixedWindowAllow reports whether key is within limit for the current window. +// When a request is denied, it also returns the milliseconds until the window resets. +func FixedWindowAllow(ctx context.Context, rdb *redis.Client, key string, + limit, windowSeconds int) (bool, int64, error) { + + script := redis.NewScript(fixedWindowScript) + res, err := script.Run(ctx, rdb, []string{key}, limit, windowSeconds).Int64Slice() + if err != nil { + return false, 0, err + } + + allowed := res[0] == 1 + if allowed { + return true, 0, nil + } + return false, res[1], nil +} +``` + +**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. + +```go +import ( + "context" + "crypto/rand" + "encoding/hex" + + "github.com/redis/go-redis/v9" +) + +const 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} +` + +// SlidingWindowLogAllow reports whether key is within limit over the trailing +// window, and the milliseconds until the oldest entry leaves it. +func SlidingWindowLogAllow(ctx context.Context, rdb *redis.Client, key string, + limit, windowSeconds int) (bool, int64, error) { + + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return false, 0, err + } + member := hex.EncodeToString(buf) + + script := redis.NewScript(slidingWindowLogScript) + res, err := script.Run(ctx, rdb, []string{key}, limit, windowSeconds, member).Int64Slice() + if err != nil { + return false, 0, err + } + return res[0] == 1, res[1], nil +} +``` + +**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. + +```go +import ( + "context" + + "github.com/redis/go-redis/v9" +) + +const 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} +` + +// SlidingWindowCounterAllow reports whether key is within limit, weighting the +// previous window's count by how far the current window has progressed. +func SlidingWindowCounterAllow(ctx context.Context, rdb *redis.Client, key string, + limit, windowSeconds int) (bool, error) { + + script := redis.NewScript(slidingWindowCounterScript) + res, err := script.Run(ctx, rdb, []string{"{" + key + "}"}, limit, windowSeconds).Int64Slice() + if err != nil { + return false, err + } + return res[0] == 1, nil +} +``` + +**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. + +```go +import ( + "context" + + "github.com/redis/go-redis/v9" +) + +const 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} +` + +// LeakyBucketAllow adds one request to the bucket for key and reports whether it +// fits. When the bucket is full it returns the milliseconds until it drains enough. +func LeakyBucketAllow(ctx context.Context, rdb *redis.Client, key string, + capacity int, leakRate float64) (bool, int64, error) { + + script := redis.NewScript(leakyBucketScript) + res, err := script.Run(ctx, rdb, []string{key}, capacity, leakRate).Int64Slice() + if err != nil { + return false, 0, err + } + return res[0] == 1, res[1], nil +} +``` + +**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 diff --git a/content/develop/use-cases/rate-limiter/java-jedis/_index.md b/content/develop/use-cases/rate-limiter/java-jedis/_index.md index aac8317403..6955bb5164 100644 --- a/content/develop/use-cases/rate-limiter/java-jedis/_index.md +++ b/content/develop/use-cases/rate-limiter/java-jedis/_index.md @@ -331,6 +331,277 @@ try { } ``` +## 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. + +```java +import java.util.List; + +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; + +public class FixedWindowLimiter { + + private static final String SCRIPT = """ + 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} + """; + + public record Decision(boolean allowed, long retryAfterMs) {} + + @SuppressWarnings("unchecked") + public static Decision allow(JedisPool pool, String key, int limit, int windowSeconds) { + try (Jedis jedis = pool.getResource()) { + List result = (List) jedis.eval( + SCRIPT, + List.of(key), + List.of(String.valueOf(limit), String.valueOf(windowSeconds))); + + boolean allowed = result.get(0) == 1L; + return new Decision(allowed, allowed ? 0L : result.get(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. + +```java +import java.util.List; +import java.util.UUID; + +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; + +public class SlidingWindowLogLimiter { + + private static final String SCRIPT = """ + 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} + """; + + public record Decision(boolean allowed, long retryAfterMs) {} + + @SuppressWarnings("unchecked") + public static Decision allow(JedisPool pool, String key, int limit, int windowSeconds) { + try (Jedis jedis = pool.getResource()) { + List result = (List) jedis.eval( + SCRIPT, + List.of(key), + List.of(String.valueOf(limit), + String.valueOf(windowSeconds), + UUID.randomUUID().toString())); + + return new Decision(result.get(0) == 1L, result.get(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. + +```java +import java.util.List; + +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; + +public class SlidingWindowCounterLimiter { + + private static final String SCRIPT = """ + 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} + """; + + @SuppressWarnings("unchecked") + public static boolean allow(JedisPool pool, String key, int limit, int windowSeconds) { + try (Jedis jedis = pool.getResource()) { + List result = (List) jedis.eval( + SCRIPT, + List.of("{" + key + "}"), + List.of(String.valueOf(limit), String.valueOf(windowSeconds))); + + return result.get(0) == 1L; + } + } +} +``` + +**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. + +```java +import java.util.List; + +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; + +public class LeakyBucketLimiter { + + private static final String SCRIPT = """ + 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} + """; + + public record Decision(boolean allowed, long retryAfterMs) {} + + @SuppressWarnings("unchecked") + public static Decision allow(JedisPool pool, String key, int capacity, double leakRate) { + try (Jedis jedis = pool.getResource()) { + List result = (List) jedis.eval( + SCRIPT, + List.of(key), + List.of(String.valueOf(capacity), String.valueOf(leakRate))); + + return new Decision(result.get(0) == 1L, result.get(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 diff --git a/content/develop/use-cases/rate-limiter/java-lettuce/_index.md b/content/develop/use-cases/rate-limiter/java-lettuce/_index.md index 1d06921f3c..5e0caf54c4 100644 --- a/content/develop/use-cases/rate-limiter/java-lettuce/_index.md +++ b/content/develop/use-cases/rate-limiter/java-lettuce/_index.md @@ -423,6 +423,273 @@ try { Lettuce also supports automatic reconnection by default. If the connection to Redis is temporarily lost, Lettuce will attempt to reconnect and replay queued commands, providing better resilience than Jedis out of the box. +## 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. + +```java +import java.util.List; + +import io.lettuce.core.ScriptOutputType; +import io.lettuce.core.api.sync.RedisCommands; + +public class FixedWindowLimiter { + + private static final String SCRIPT = """ + 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} + """; + + public record Decision(boolean allowed, long retryAfterMs) {} + + public static Decision allow(RedisCommands commands, String key, + int limit, int windowSeconds) { + List result = commands.eval( + SCRIPT, + ScriptOutputType.MULTI, + new String[]{key}, + String.valueOf(limit), String.valueOf(windowSeconds)); + + boolean allowed = result.get(0) == 1L; + return new Decision(allowed, allowed ? 0L : result.get(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. + +```java +import java.util.List; +import java.util.UUID; + +import io.lettuce.core.ScriptOutputType; +import io.lettuce.core.api.sync.RedisCommands; + +public class SlidingWindowLogLimiter { + + private static final String SCRIPT = """ + 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} + """; + + public record Decision(boolean allowed, long retryAfterMs) {} + + public static Decision allow(RedisCommands commands, String key, + int limit, int windowSeconds) { + List result = commands.eval( + SCRIPT, + ScriptOutputType.MULTI, + new String[]{key}, + String.valueOf(limit), + String.valueOf(windowSeconds), + UUID.randomUUID().toString()); + + return new Decision(result.get(0) == 1L, result.get(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. + +```java +import java.util.List; + +import io.lettuce.core.ScriptOutputType; +import io.lettuce.core.api.sync.RedisCommands; + +public class SlidingWindowCounterLimiter { + + private static final String SCRIPT = """ + 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} + """; + + public static boolean allow(RedisCommands commands, String key, + int limit, int windowSeconds) { + List result = commands.eval( + SCRIPT, + ScriptOutputType.MULTI, + new String[]{"{" + key + "}"}, + String.valueOf(limit), String.valueOf(windowSeconds)); + + return result.get(0) == 1L; + } +} +``` + +**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. + +```java +import java.util.List; + +import io.lettuce.core.ScriptOutputType; +import io.lettuce.core.api.sync.RedisCommands; + +public class LeakyBucketLimiter { + + private static final String SCRIPT = """ + 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} + """; + + public record Decision(boolean allowed, long retryAfterMs) {} + + public static Decision allow(RedisCommands commands, String key, + int capacity, double leakRate) { + List result = commands.eval( + SCRIPT, + ScriptOutputType.MULTI, + new String[]{key}, + String.valueOf(capacity), String.valueOf(leakRate)); + + return new Decision(result.get(0) == 1L, result.get(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 diff --git a/content/develop/use-cases/rate-limiter/nodejs/_index.md b/content/develop/use-cases/rate-limiter/nodejs/_index.md index c8ea0840e8..a05c80dc51 100644 --- a/content/develop/use-cases/rate-limiter/nodejs/_index.md +++ b/content/develop/use-cases/rate-limiter/nodejs/_index.md @@ -286,6 +286,229 @@ try { } ``` +## 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. + +```javascript +const FIXED_WINDOW_SCRIPT = ` +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. +async function fixedWindowAllow(client, key, limit, windowSeconds) { + const [allowed, ttl] = await client.eval(FIXED_WINDOW_SCRIPT, { + keys: [key], + arguments: [String(limit), String(windowSeconds)], + }); + + return { + allowed: allowed === 1, + retryAfterMs: allowed === 1 ? 0 : Number(ttl), + }; +} +``` + +**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. + +```javascript +const { randomUUID } = require("crypto"); + +const SLIDING_WINDOW_LOG_SCRIPT = ` +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} +`; + +async function slidingWindowLogAllow(client, key, limit, windowSeconds) { + const [allowed, retryAfterMs] = await client.eval(SLIDING_WINDOW_LOG_SCRIPT, { + keys: [key], + arguments: [String(limit), String(windowSeconds), randomUUID()], + }); + + return { allowed: allowed === 1, retryAfterMs: Number(retryAfterMs) }; +} +``` + +**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. + +```javascript +const SLIDING_WINDOW_COUNTER_SCRIPT = ` +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} +`; + +async function slidingWindowCounterAllow(client, key, limit, windowSeconds) { + const [allowed] = await client.eval(SLIDING_WINDOW_COUNTER_SCRIPT, { + keys: [`{${key}}`], + arguments: [String(limit), String(windowSeconds)], + }); + + return { allowed: allowed === 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. + +```javascript +const LEAKY_BUCKET_SCRIPT = ` +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} +`; + +async function leakyBucketAllow(client, key, capacity, leakRate) { + const [allowed, retryAfterMs] = await client.eval(LEAKY_BUCKET_SCRIPT, { + keys: [key], + arguments: [String(capacity), String(leakRate)], + }); + + return { allowed: allowed === 1, retryAfterMs: Number(retryAfterMs) }; +} +``` + +**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 diff --git a/content/develop/use-cases/rate-limiter/php/_index.md b/content/develop/use-cases/rate-limiter/php/_index.md index e1b634e22f..ed666dc6ef 100644 --- a/content/develop/use-cases/rate-limiter/php/_index.md +++ b/content/develop/use-cases/rate-limiter/php/_index.md @@ -329,6 +329,245 @@ try { } ``` +## 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. + +```php + limit then + return {0, ttl} +end +return {1, ttl} +LUA; + +/** + * @return array{allowed: bool, retry_after_ms: int} + */ +function fixedWindowAllow(Client $redis, string $key, int $limit, int $windowSeconds): array +{ + [$allowed, $ttl] = $redis->eval(FIXED_WINDOW_SCRIPT, 1, $key, $limit, $windowSeconds); + + return [ + 'allowed' => $allowed === 1, + 'retry_after_ms' => $allowed === 1 ? 0 : (int) $ttl, + ]; +} +``` + +**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. + +```php +eval( + SLIDING_WINDOW_LOG_SCRIPT, 1, $key, $limit, $windowSeconds, $member + ); + + return ['allowed' => $allowed === 1, 'retry_after_ms' => (int) $retryAfterMs]; +} +``` + +**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. + +```php += 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} +LUA; + +function slidingWindowCounterAllow(Client $redis, string $key, int $limit, int $windowSeconds): array +{ + [$allowed] = $redis->eval( + SLIDING_WINDOW_COUNTER_SCRIPT, 1, '{' . $key . '}', $limit, $windowSeconds + ); + + return ['allowed' => $allowed === 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. + +```php + 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} +LUA; + +function leakyBucketAllow(Client $redis, string $key, int $capacity, float $leakRate): array +{ + [$allowed, $retryAfterMs] = $redis->eval( + LEAKY_BUCKET_SCRIPT, 1, $key, $capacity, $leakRate + ); + + return ['allowed' => $allowed === 1, 'retry_after_ms' => (int) $retryAfterMs]; +} +``` + +**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 diff --git a/content/develop/use-cases/rate-limiter/redis-py/_index.md b/content/develop/use-cases/rate-limiter/redis-py/_index.md index b5e2ba902b..26059a1fd6 100644 --- a/content/develop/use-cases/rate-limiter/redis-py/_index.md +++ b/content/develop/use-cases/rate-limiter/redis-py/_index.md @@ -237,9 +237,11 @@ their features: | [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. -All of them use `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 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 diff --git a/content/develop/use-cases/rate-limiter/ruby/_index.md b/content/develop/use-cases/rate-limiter/ruby/_index.md index 8422eaa8ca..726d693282 100644 --- a/content/develop/use-cases/rate-limiter/ruby/_index.md +++ b/content/develop/use-cases/rate-limiter/ruby/_index.md @@ -300,6 +300,226 @@ rescue Redis::BaseError => e end ``` +## 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. + +```ruby +require "redis" + +FIXED_WINDOW_SCRIPT = <<~LUA + 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} +LUA + +# Returns a hash with :allowed and :retry_after_ms keys. +def fixed_window_allow(redis, key, limit, window_seconds) + allowed, ttl = redis.eval(FIXED_WINDOW_SCRIPT, + keys: [key], argv: [limit, window_seconds]) + + { allowed: allowed == 1, retry_after_ms: allowed == 1 ? 0 : ttl } +end +``` + +**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. + +```ruby +require "redis" +require "securerandom" + +SLIDING_WINDOW_LOG_SCRIPT = <<~LUA + 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} +LUA + +def sliding_window_log_allow(redis, key, limit, window_seconds) + allowed, retry_after_ms = redis.eval(SLIDING_WINDOW_LOG_SCRIPT, + keys: [key], + argv: [limit, window_seconds, SecureRandom.uuid]) + + { allowed: allowed == 1, retry_after_ms: retry_after_ms } +end +``` + +**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. + +```ruby +require "redis" + +SLIDING_WINDOW_COUNTER_SCRIPT = <<~LUA + 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} +LUA + +def sliding_window_counter_allow(redis, key, limit, window_seconds) + allowed, = redis.eval(SLIDING_WINDOW_COUNTER_SCRIPT, + keys: ["{#{key}}"], argv: [limit, window_seconds]) + + { allowed: allowed == 1 } +end +``` + +**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. + +```ruby +require "redis" + +LEAKY_BUCKET_SCRIPT = <<~LUA + 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} +LUA + +def leaky_bucket_allow(redis, key, capacity, leak_rate) + allowed, retry_after_ms = redis.eval(LEAKY_BUCKET_SCRIPT, + keys: [key], argv: [capacity, leak_rate]) + + { allowed: allowed == 1, retry_after_ms: retry_after_ms } +end +``` + +**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 diff --git a/content/develop/use-cases/rate-limiter/rust/_index.md b/content/develop/use-cases/rate-limiter/rust/_index.md index cb4c73a31e..dbc02b4d7f 100644 --- a/content/develop/use-cases/rate-limiter/rust/_index.md +++ b/content/develop/use-cases/rate-limiter/rust/_index.md @@ -430,6 +430,261 @@ if tokens_acquired == 5 { } ``` +## 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. + +```rust +use redis::Script; + +const FIXED_WINDOW_SCRIPT: &str = r#" +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, retry_after_ms)`. `retry_after_ms` is 0 when allowed. +fn fixed_window_allow( + con: &mut dyn redis::ConnectionLike, + key: &str, + limit: i64, + window_seconds: i64, +) -> redis::RedisResult<(bool, i64)> { + let (allowed, ttl): (i64, i64) = Script::new(FIXED_WINDOW_SCRIPT) + .key(key) + .arg(limit) + .arg(window_seconds) + .invoke(con)?; + + Ok((allowed == 1, if allowed == 1 { 0 } else { ttl })) +} +``` + +**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. + +```rust +// Add to Cargo.toml: uuid = { version = "1", features = ["v4"] } +use redis::Script; +use uuid::Uuid; + +const SLIDING_WINDOW_LOG_SCRIPT: &str = r#" +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} +"#; + +fn sliding_window_log_allow( + con: &mut dyn redis::ConnectionLike, + key: &str, + limit: i64, + window_seconds: i64, +) -> redis::RedisResult<(bool, i64)> { + let member = Uuid::new_v4().to_string(); + + let (allowed, retry_after_ms): (i64, i64) = Script::new(SLIDING_WINDOW_LOG_SCRIPT) + .key(key) + .arg(limit) + .arg(window_seconds) + .arg(member) + .invoke(con)?; + + Ok((allowed == 1, retry_after_ms)) +} +``` + +**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. + +```rust +use redis::Script; + +const SLIDING_WINDOW_COUNTER_SCRIPT: &str = r#" +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} +"#; + +fn sliding_window_counter_allow( + con: &mut dyn redis::ConnectionLike, + key: &str, + limit: i64, + window_seconds: i64, +) -> redis::RedisResult { + let (allowed, _): (i64, i64) = Script::new(SLIDING_WINDOW_COUNTER_SCRIPT) + .key(format!("{{{}}}", key)) + .arg(limit) + .arg(window_seconds) + .invoke(con)?; + + Ok(allowed == 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. + +```rust +use redis::Script; + +const LEAKY_BUCKET_SCRIPT: &str = r#" +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} +"#; + +fn leaky_bucket_allow( + con: &mut dyn redis::ConnectionLike, + key: &str, + capacity: i64, + leak_rate: f64, +) -> redis::RedisResult<(bool, i64)> { + let (allowed, retry_after_ms): (i64, i64) = Script::new(LEAKY_BUCKET_SCRIPT) + .key(key) + .arg(capacity) + .arg(leak_rate) + .invoke(con)?; + + Ok((allowed == 1, retry_after_ms)) +} +``` + +**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 From ec63c7cf4234185a9f10bb997eee151b8ada764c Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 26 Aug 2026 13:04:50 +0100 Subject: [PATCH 2/2] DOC-6999: backfill the Installation and EVALSHA sections on the redis-py page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other rate limiter client page explains that the limiter caches its script and sends EVALSHA, and every one tells you how to install the client. The redis-py page did neither, even though its own token_bucket.py has done the SHA1-plus-script-load-plus-fallback dance since it was written. This adds both sections in the position the eight sibling pages use. The wire behaviour in the new section was traced with MONITOR rather than read off the source. TokenBucket sends SCRIPT LOAD on the first allow and EVALSHA after that, as expected. The part worth writing down is the alternative algorithm snippets, which call register_script on every request and look wasteful: they are not, because redis-py derives the digest from the script text client-side, so a freshly built Script object has the same digest and EVALSHA still hits the cached script. Three calls through a per-request register_script produced three EVALSHA commands and no extra round trip. Installation stays shorter than the ruby and rust equivalents on purpose, since naming a minimum redis-py version would have meant inventing one. Learned: redis-py computes the EVALSHA digest client-side from the script text, so re-registering a Script per call costs nothing on the wire — MONITOR-verified, not inferred Rejected: pinning a version floor in the Installation section the way the ruby and rust pages pin theirs | the floor was never verified and a wrong minimum misleads more than an absent one Ticket: DOC-6999 Co-Authored-By: Claude Opus 5 (1M context) --- .../use-cases/rate-limiter/redis-py/_index.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/content/develop/use-cases/rate-limiter/redis-py/_index.md b/content/develop/use-cases/rate-limiter/redis-py/_index.md index 26059a1fd6..fc675938fb 100644 --- a/content/develop/use-cases/rate-limiter/redis-py/_index.md +++ b/content/develop/use-cases/rate-limiter/redis-py/_index.md @@ -117,6 +117,14 @@ Without atomic execution, race conditions could occur: Using [`EVAL`]({{< relref "/commands/eval" >}}) or [`EVALSHA`]({{< relref "/commands/evalsha" >}}) ensures the entire operation executes atomically, making it safe for distributed systems. +## Installation + +Install the `redis` package: + +```bash +pip install redis +``` + ## Using the Python module The `TokenBucket` class provides a simple interface for rate limiting @@ -168,6 +176,28 @@ The `key` parameter identifies what you're rate limiting. Common patterns: * **Per API endpoint**: `api:{endpoint}:{user_id}` - Different limits per endpoint * **Global**: `global:api` - Single limit shared across all requests +### Script caching with EVALSHA + +The Python module uses [`EVALSHA`]({{< relref "/commands/evalsha" >}}) for optimal +performance. `TokenBucket` computes the script's SHA1 digest when you create it, +loads the script into Redis with `SCRIPT LOAD` on first use, and sends every +later request as `EVALSHA`. If the script has been evicted from the server's +cache, it catches the resulting error, falls back to +[`EVAL`]({{< relref "/commands/eval" >}}), and reloads the script. + +```python +limiter = TokenBucket(capacity=10, refill_rate=1, refill_interval=1.0) + +limiter.allow('user:123') # SCRIPT LOAD, then EVALSHA +limiter.allow('user:123') # EVALSHA against the cached script +``` + +The [alternative algorithm examples](#alternative-rate-limiting-algorithms) use +`register_script()` instead, which wraps the same behavior in a `Script` object. +That object derives the digest from the script text rather than from per-call +state, so re-registering on each request adds no round trip: the digest is +unchanged and `EVALSHA` still hits the cached script. + ## Running the demo ### Get the source files