GCRA Rate Limiter (#14826)

# What

Implement rate limiting functionality via GCRA algorithm. 

Introduce a new command `GCRA` to facilitate it.

The implementation is heavily based on the popular
[redis-cell](https://github.com/brandur/redis-cell) module (by
[brandur](https://github.com/brandur)) with small changes in the API.

# Why

Rate limiting is a very common use case of redis and GCRA is one of the
most popular algorithms used because of its simplicity and speed.
Currently rate limiting with GCRA is possible via lua scripts or even
client libraries via the relatively recent `SET IFEQ`/`DIGEST` commands
([redis-py
example](https://gist.github.com/minchopaskal/b7acd4550f7144b88e2d0f86568a0d7b)).
Implementing it directly inside redis gives us even faster performance.

# API

```
GCRA key max_burst requests_per_period period [NUM_REQUESTS count]
```

## Description

Rate limit via GCRA. `requests_per_period` are allowed per `period` at a
sustained rate. Thus we have a minimum spacing(emission interval) of
`period`/`requests_per_period` seconds between each request. `max_burst`
allows for occasional spikes by granting up to `max_burst` additional
requests to be consumed at once. See more in the [GCRA
wiki](https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm).

## Options

**KEY** - key related to specific rate limiting case
**MAX_BURST** - maximum number of tokens allowed as a burst (in addition
to the sustained rate). Min: 0
**REQUESTS_PER_PERIOD** - number of requests allowed per PERIOD. Min: 1
**PERIOD** - period in seconds as floating point number used for
calculating the sustained rate. Min: 1.0
**NUM_REQUESTS** - cost (or weight) of this rate-limiting request. A
higher cost drains the allowance faster. Default: 1

### Note

In redis-cell module and most other modules that are based on it PERIOD
is given in seconds as integer. We decided to use floating point for
greater flexibility. Internally time periods are calculated in
microsecond granularity.

## Reply

Reply is identical to reply of redis-cell 

```
127.0.0.1:6379> GCRA <key> <max_burst> <requests_per_period> <period> NUM_REQUESTS <count>
1) <limited> # 0 or 1
2) <max-req-num> # max number of request. Always equal to max_burst+1
3) <num-avail-req> # number of requests available immediately
4) <reply-after> # number of seconds after which caller should retry. Always returns -1 if request isn't limited.
5) <full-burst-after> # number of seconds after which a full burst will be allowed
```

---------

Co-authored-by: debing.sun <debing.sun@redis.com>
This commit is contained in:
Mincho Paskalev 2026-03-18 13:28:05 +02:00 committed by GitHub
parent 44157ee35e
commit 31a4356ac0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 630 additions and 1 deletions

View file

@ -382,7 +382,7 @@ endif
REDIS_SERVER_NAME=redis-server$(PROG_SUFFIX)
REDIS_SENTINEL_NAME=redis-sentinel$(PROG_SUFFIX)
REDIS_SERVER_OBJ=threads_mngr.o memory_prefetch.o adlist.o quicklist.o ae.o anet.o dict.o ebuckets.o eventnotifier.o iothread.o mstr.o entry.o kvstore.o fwtree.o estore.o server.o sds.o zmalloc.o lzf_c.o lzf_d.o pqsort.o zipmap.o sha1.o ziplist.o release.o networking.o util.o object.o db.o replication.o rdb.o t_string.o t_list.o t_set.o t_zset.o t_hash.o config.o aof.o pubsub.o multi.o debug.o sort.o intset.o syncio.o cluster.o cluster_asm.o cluster_legacy.o cluster_slot_stats.o crc16.o endianconv.o slowlog.o eval.o bio.o rio.o rand.o memtest.o syscheck.o crcspeed.o crccombine.o crc64.o bitops.o sentinel.o notify.o setproctitle.o blocked.o hyperloglog.o latency.o sparkline.o redis-check-rdb.o redis-check-aof.o geo.o lazyfree.o module.o evict.o expire.o geohash.o geohash_helper.o childinfo.o defrag.o siphash.o rax.o t_stream.o listpack.o localtime.o lolwut.o lolwut5.o lolwut6.o lolwut8.o acl.o tracking.o socket.o tls.o sha256.o timeout.o setcpuaffinity.o monotonic.o mt19937-64.o resp_parser.o call_reply.o script_lua.o script.o functions.o function_lua.o commands.o strl.o connection.o unix.o logreqres.o keymeta.o chk.o hotkeys.o
REDIS_SERVER_OBJ=threads_mngr.o memory_prefetch.o adlist.o quicklist.o ae.o anet.o dict.o ebuckets.o eventnotifier.o iothread.o mstr.o entry.o kvstore.o fwtree.o estore.o server.o sds.o zmalloc.o lzf_c.o lzf_d.o pqsort.o zipmap.o sha1.o ziplist.o release.o networking.o util.o object.o db.o replication.o rdb.o t_string.o t_list.o t_set.o t_zset.o t_hash.o config.o aof.o pubsub.o multi.o debug.o sort.o intset.o syncio.o cluster.o cluster_asm.o cluster_legacy.o cluster_slot_stats.o crc16.o endianconv.o slowlog.o eval.o bio.o rio.o rand.o memtest.o syscheck.o crcspeed.o crccombine.o crc64.o bitops.o sentinel.o notify.o setproctitle.o blocked.o hyperloglog.o latency.o sparkline.o redis-check-rdb.o redis-check-aof.o geo.o lazyfree.o module.o evict.o expire.o geohash.o geohash_helper.o childinfo.o defrag.o siphash.o rax.o t_stream.o listpack.o localtime.o lolwut.o lolwut5.o lolwut6.o lolwut8.o acl.o tracking.o socket.o tls.o sha256.o timeout.o setcpuaffinity.o monotonic.o mt19937-64.o resp_parser.o call_reply.o script_lua.o script.o functions.o function_lua.o commands.o strl.o connection.o unix.o logreqres.o keymeta.o chk.o hotkeys.o gcra.o
REDIS_CLI_NAME=redis-cli$(PROG_SUFFIX)
REDIS_CLI_OBJ=anet.o adlist.o dict.o redis-cli.o zmalloc.o release.o ae.o redisassert.o crcspeed.o crccombine.o crc64.o siphash.o crc16.o monotonic.o cli_common.o mt19937-64.o strl.o cli_commands.o
REDIS_BENCHMARK_NAME=redis-benchmark$(PROG_SUFFIX)

View file

@ -11039,6 +11039,34 @@ struct COMMAND_ARG DIGEST_Args[] = {
{MAKE_ARG("key",ARG_TYPE_KEY,0,NULL,NULL,NULL,CMD_ARG_NONE,0,NULL)},
};
/********** GCRA ********************/
#ifndef SKIP_CMD_HISTORY_TABLE
/* GCRA history */
#define GCRA_History NULL
#endif
#ifndef SKIP_CMD_TIPS_TABLE
/* GCRA tips */
#define GCRA_Tips NULL
#endif
#ifndef SKIP_CMD_KEY_SPECS_TABLE
/* GCRA key specs */
keySpec GCRA_Keyspecs[1] = {
{NULL,CMD_KEY_RW|CMD_KEY_ACCESS|CMD_KEY_UPDATE,KSPEC_BS_INDEX,.bs.index={1},KSPEC_FK_RANGE,.fk.range={0,1,0}}
};
#endif
/* GCRA argument table */
struct COMMAND_ARG GCRA_Args[] = {
{MAKE_ARG("key",ARG_TYPE_KEY,0,NULL,NULL,NULL,CMD_ARG_NONE,0,NULL)},
{MAKE_ARG("max-burst",ARG_TYPE_INTEGER,-1,NULL,NULL,NULL,CMD_ARG_NONE,0,NULL)},
{MAKE_ARG("requests-per-period",ARG_TYPE_INTEGER,-1,NULL,NULL,NULL,CMD_ARG_NONE,0,NULL)},
{MAKE_ARG("period",ARG_TYPE_DOUBLE,-1,NULL,NULL,NULL,CMD_ARG_NONE,0,NULL)},
{MAKE_ARG("count",ARG_TYPE_INTEGER,-1,"NUM_REQUESTS",NULL,NULL,CMD_ARG_OPTIONAL,0,NULL)},
};
/********** GET ********************/
#ifndef SKIP_CMD_HISTORY_TABLE
@ -11969,6 +11997,7 @@ struct COMMAND_STRUCT redisCommandTable[] = {
{MAKE_CMD("decrby","Decrements a number from the integer value of a key. Uses 0 as initial value if the key doesn't exist.","O(1)","1.0.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,DECRBY_History,0,DECRBY_Tips,0,decrbyCommand,3,CMD_WRITE|CMD_DENYOOM|CMD_FAST,ACL_CATEGORY_STRING,DECRBY_Keyspecs,1,NULL,2),.args=DECRBY_Args},
{MAKE_CMD("delex","Conditionally removes the specified key based on value or digest comparison.","O(1) for IFEQ/IFNE, O(N) for IFDEQ/IFDNE where N is the length of the string value.","8.4.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,DELEX_History,0,DELEX_Tips,0,delexCommand,-2,CMD_WRITE|CMD_FAST,ACL_CATEGORY_STRING,DELEX_Keyspecs,1,delexGetKeys,2),.args=DELEX_Args},
{MAKE_CMD("digest","Returns the XXH3 hash of a string value.","O(N) where N is the length of the string value.","8.4.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,DIGEST_History,0,DIGEST_Tips,0,digestCommand,2,CMD_READONLY|CMD_FAST,ACL_CATEGORY_STRING,DIGEST_Keyspecs,1,NULL,1),.args=DIGEST_Args},
{MAKE_CMD("gcra","Rate limit via GCRA (Generic Cell Rate Algorithm).","O(1)","8.8.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,GCRA_History,0,GCRA_Tips,0,gcraCommand,-5,CMD_WRITE|CMD_DENYOOM|CMD_FAST,ACL_CATEGORY_STRING,GCRA_Keyspecs,1,NULL,5),.args=GCRA_Args},
{MAKE_CMD("get","Returns the string value of a key.","O(1)","1.0.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,GET_History,0,GET_Tips,0,getCommand,2,CMD_READONLY|CMD_FAST,ACL_CATEGORY_STRING,GET_Keyspecs,1,NULL,1),.args=GET_Args},
{MAKE_CMD("getdel","Returns the string value of a key after deleting the key.","O(1)","6.2.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,GETDEL_History,0,GETDEL_Tips,0,getdelCommand,2,CMD_WRITE|CMD_FAST,ACL_CATEGORY_STRING,GETDEL_Keyspecs,1,NULL,1),.args=GETDEL_Args},
{MAKE_CMD("getex","Returns the string value of a key after setting its expiration time.","O(1)","6.2.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,GETEX_History,0,GETEX_Tips,0,getexCommand,-2,CMD_WRITE|CMD_FAST,ACL_CATEGORY_STRING,GETEX_Keyspecs,1,NULL,2),.args=GETEX_Args},

92
src/commands/gcra.json Normal file
View file

@ -0,0 +1,92 @@
{
"GCRA": {
"summary": "Rate limit via GCRA (Generic Cell Rate Algorithm).",
"complexity": "O(1)",
"group": "string",
"since": "8.8.0",
"arity": -5,
"function": "gcraCommand",
"command_flags": [
"WRITE",
"DENYOOM",
"FAST"
],
"acl_categories": [
"STRING"
],
"key_specs": [
{
"flags": [
"RW",
"ACCESS",
"UPDATE"
],
"begin_search": {
"index": {
"pos": 1
}
},
"find_keys": {
"range": {
"lastkey": 0,
"step": 1,
"limit": 0
}
}
}
],
"reply_schema": {
"type": "array",
"minItems": 5,
"maxItems": 5,
"description": "Rate limiting result",
"items": [
{
"type": "integer",
"description": "Limited: 0 if allowed, 1 if rate limited"
},
{
"type": "integer",
"description": "Max request number: always equal to max_burst+1"
},
{
"type": "integer",
"description": "Number of requests available immediately"
},
{
"type": "integer",
"description": "Retry after: seconds after which caller should retry. Always -1 if not limited"
},
{
"type": "integer",
"description": "Full burst after: seconds after which a full burst will be allowed"
}
]
},
"arguments": [
{
"name": "key",
"type": "key",
"key_spec_index": 0
},
{
"name": "max-burst",
"type": "integer"
},
{
"name": "requests-per-period",
"type": "integer"
},
{
"name": "period",
"type": "double"
},
{
"name": "count",
"type": "integer",
"token": "NUM_REQUESTS",
"optional": true
}
]
}
}

241
src/gcra.c Normal file
View file

@ -0,0 +1,241 @@
/*
* Copyright (c) 2026-Present, Redis Ltd.
* All rights reserved.
*
* Licensed under your choice of (a) the Redis Source Available License 2.0
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
* GNU Affero General Public License v3 (AGPLv3).
*/
#include "server.h"
#include <math.h>
/* GCRA algorithm for rate limiting.
* Implementation is heavily based on the implementation of (redis-cell)
* [https://github.com/brandur/redis-cell] by (brandur)[https://github.com/brandur].
*
* It is a leaky-bucket type algorithm but instead of periodically dripping, we
* calculate the next time the bucket has capacity - called
* Theoretical arrival time(TaT) by the algorithm. We allow requests at a
* sustained rate (f.e 5 request per 10 seconds, i.e 1 request per 2 seconds)
* but also allow bursts of multiple request at one time.
*
* Explanation of the algorithm follows using the leaky-bucket analogy.
*
* GCRA works by keeping track of the next TaT and updating it after requests
* are allowed. Let T be the emission interval for a request - in the
* leaky-bucket analogy this will be the period at which the bucket drips.
* Using N requests will result in the (next TaT) = (current TaT) + N * T (time
* needed to drain the bucket). To determine if a request can be allowed we can
* calculate the time at which "the bucket dripped", which is TaT-T.
* If this time is in the past the request is allowed, otherwise we wait and TaT
* is not updated. This only accounts for 1 request though. In order to allow
* bursts we can imagine a full burst fully filling an empty bucket, thus
* we need to calculate the time after which "the bucket will completely drain"
* the requests of the burst - this will be t = T * max_burst.
* At last the allowance check will be:
*
* "now" >= TaT - (T + t)
*
* And in this case a picture is worth about 250 words:
*
* +-------------------+
* | ALLOWED REQUEST |
* +-------------------+
*
* +-----------+ +-----+ +-----+
* | allow at | | now | | TaT |
* | (past) | +-----+ +-----+
* +-----------+ | |
* | |
* ---+-----------------------+----------+-----------> time
* |//////////////////////////////////|
* |//////////////////////////////////|
* +----------------------------------+
* | |
* |<------------- t + T ------------>|
*
*
* +------------------------------------------+
* | T = Emission interval |
* | t = Capacity of bucket |
* | t + T = Delay variation tolerance |
* | tat = Theoretical arrival time |
* | now = Actual time of request |
* +------------------------------------------+
*
* (ASCII art adapted from https://brandur.org/rate-limiting). */
/* GCRA key max_burst requests_per_period period [NUM_REQUESTS count]
*
* key: Key related to specific rate limiting case
* max_burst: Maximum requests allowed as burst (in addition to sustained rate)
* requests_per_period: Number of requests allowed per period
* period: Period in seconds for calculating sustained rate
* num_requests: Optional, cost of this request (default: 1)
*/
void gcraCommand(client *c) {
robj *key = c->argv[1];
/* GCRA parameters */
long max_burst;
long requests_per_period;
long num_requests = 1;
double period;
/* Variables used in the reply */
int limited; /* Whether or not the request was limited */
long long remaining = 0; /* Number of requests available immediately */
long long retry_after_s = -1; /* Time in seconds after which the caller can retry */
long long reset_after_s = 0; /* Number of seconds after which a full burst will be allowed */
if (c->argc > 7) {
addReplyErrorArity(c);
return;
}
if (getPositiveLongFromObjectOrReply(c, c->argv[2], &max_burst, NULL) != C_OK) {
return;
}
if (likely(max_burst < LONG_MAX)) max_burst += 1;
if (getRangeLongFromObjectOrReply(c, c->argv[3], 1, LONG_MAX, &requests_per_period, NULL) != C_OK) {
return;
}
if (getDoubleFromObjectOrReply(c, c->argv[4], &period, NULL) != C_OK) {
return;
}
if (period <= 0 || period >= 1e12) {
addReplyError(c, "period must be > 0 and < 1e12");
return;
}
if (c->argc >= 6) {
if (strcasecmp("NUM_REQUESTS", c->argv[5]->ptr)) {
addReplyErrorObject(c, shared.syntaxerr);
return;
}
if (c->argc == 6) {
addReplyError(c, "Missing NUM_REQUESTS value");
return;
}
if (getRangeLongFromObjectOrReply(c, c->argv[6], 1, LONG_MAX, &num_requests, NULL) != C_OK) {
return;
}
}
ustime_t now = commandTimeSnapshot() * 1000;
long long tat_us, new_tat_us;
dictEntryLink link;
kvobj *kv = lookupKeyWriteWithLink(c->db, key, &link);
if (checkType(c, kv, OBJ_STRING)) {
return;
}
if (kv != NULL) {
/* Note the value of the key may have been overwritten outside of the
* GCRA command (f.e by calling SET). We don't try to catch such errors
* as this would be possible only with a dedicated structures for GCRA,
* while using STRING gives us all the benefits of a redis key -
* replication, setting expiration, etc. */
if (getLongLongFromObject(kv, &tat_us) != C_OK) {
addReplyError(c, "Invalid GCRA key");
return;
}
if (tat_us <= 0) {
addReplyError(c, "Negative time is invalid value for GCRA");
return;
}
} else {
tat_us = now;
}
/* microsecond accuracy */
double period_us = period * 1000000.;
/* Emission interval is the minimum amount of time between requests.
* Note on calculations:
* Even if emission_interval_us becomes less than 1us, we assume it's min
* 1ms. The API is already in seconds granularity so it is expected the user
* won't need a submicrosecond accuracy. */
long long emission_interval_us = (long long)(period_us / requests_per_period + 0.5);
if (unlikely(emission_interval_us == 0)) emission_interval_us = 1;
/* overflow checks. In normal circumstances we shouldn't get these but the
* user may have wrongfully specified very large values.
* Note that all values are positive. */
if (emission_interval_us > LLONG_MAX / num_requests) {
addReplyError(c, "GCRA limiting uses microsecond accuracy. Combination of period, requests_per_period and num_requests would cause an overflow");
return;
}
if (emission_interval_us > LLONG_MAX / max_burst) {
addReplyError(c, "GCRA limiting uses microsecond accuracy. Combination of period, requests_per_period and max_burst would cause an overflow");
return;
}
/* Max bursts give us an amount of requests we can use up at one time.
* The variance will calculate the amount of time that many request need
* to "refill the bucket". */
long long variance_us = emission_interval_us * max_burst;
/* If a request is allowed the next TaT is after an emission_interval_us time.
* Hence for multiple requests we multiple by their number. */
long long increment_us = emission_interval_us * num_requests;
long long base_us = (now > tat_us) ? now : tat_us;
if (LLONG_MAX - base_us < increment_us) {
addReplyError(c, "GCRA limiting uses microsecond accuracy. Combination of period, requests_per_period and num_requests would cause an overflow");
return;
}
new_tat_us = base_us + increment_us;
/* Calculate the time a request is allowed. This is TaT, but because we allow
* a burst we move that time in the past. If the allow time is before the
* time we ask (i.e now) we allow the request, otherwise we limit it and
* calculate after how much time the user should retry. */
long long allow_at = new_tat_us - variance_us;
long long diff_us = now - allow_at;
long long ttl_us;
if (diff_us < 0) {
limited = 1;
/* NOTE: if increment is more than variance, then number of requests is
* more than what is maximally allowed (i.e max_bursts + 1) so we leave
* retry_after_s to -1 in this case, as it should never be retried. */
if (increment_us <= variance_us) {
retry_after_s = ceil((-diff_us) / 1000000.);
}
ttl_us = tat_us - now;
} else {
limited = 0;
ttl_us = new_tat_us - now;
robj *tatobj = createStringObjectFromLongLong(new_tat_us);
setKeyByLink(c, c->db, key, &tatobj, kv ? SETKEY_ALREADY_EXIST : SETKEY_DOESNT_EXIST, &link);
notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id);
long long when = new_tat_us / 1000;
kv = setExpireByLink(c, c->db, key->ptr, when, link);
notifyKeyspaceEvent(NOTIFY_GENERIC,"expire",key,c->db->id);
/* Replicating the command directly would mess up TaT as we use
* commandTimeSnapshot. We instead rewrite the command as SET with the
* appropriate expire time. */
robj *pexat_obj = createStringObjectFromLongLong(when);
rewriteClientCommandVector(c, 5, shared.set, key, kv, shared.pxat, pexat_obj);
decrRefCount(pexat_obj);
server.dirty++;
}
long long next_us = variance_us - ttl_us;
if (next_us > -emission_interval_us) {
remaining = next_us / emission_interval_us;
}
reset_after_s = ceil(ttl_us / 1000000.);
addReplyArrayLen(c, 5);
addReply(c, limited ? shared.cone : shared.czero);
addReplyLongLong(c, max_burst);
addReplyLongLong(c, remaining);
addReplyLongLong(c, retry_after_s);
addReplyLongLong(c, reset_after_s);
}

View file

@ -4437,6 +4437,7 @@ void quitCommand(client *c);
void resetCommand(client *c);
void failoverCommand(client *c);
void digestCommand(client *c);
void gcraCommand(client *c);
#if defined(__GNUC__)
void *calloc(size_t count, size_t size) __attribute__ ((deprecated));

266
tests/unit/gcra.tcl Normal file
View file

@ -0,0 +1,266 @@
start_server {tags {"gcra" "external:skip"}} {
test {GCRA - argument validation} {
# Wrong number of arguments (too few)
catch {r gcra} err
assert_match "*wrong number of arguments*" $err
catch {r gcra mykey} err
assert_match "*wrong number of arguments*" $err
catch {r gcra mykey 10} err
assert_match "*wrong number of arguments*" $err
catch {r gcra mykey 10 5} err
assert_match "*wrong number of arguments*" $err
# max_burst must be non-negative integer
catch {r gcra mykey -1 5 10} err
assert_match "*out of range*" $err
catch {r gcra mykey notanumber 5 10} err
assert_match "*out of range*" $err
# tokens_per_period must be >= 1
catch {r gcra mykey 10 0 10} err
assert_match "*out of range*" $err
catch {r gcra mykey 10 -1 10} err
assert_match "*out of range*" $err
catch {r gcra mykey 10 notanumber 10} err
assert_match "*not an integer*" $err
# period must be positive
catch {r gcra mykey 10 5 0} err
assert_match "*period must be > 0*" $err
catch {r gcra mykey 10 5 -1} err
assert_match "*period must be > 0*" $err
catch {r gcra mykey 10 5 notanumber} err
assert_match "*not a valid float*" $err
# tokens (optional) must be >= 1
catch {r gcra mykey 10 5 10 NUM_REQUESTS} err
assert_match "*Missing NUM_REQUESTS value*" $err
catch {r gcra mykey 10 5 10 NUM_REQUESTS 0} err
assert_match "*out of range*" $err
catch {r gcra mykey 10 5 10 NUM_REQUESTS -1} err
assert_match "*out of range*" $err
catch {r gcra mykey 10 5 10 NUM_REQUESTS notanumber} err
assert_match "*not an integer*" $err
# Valid arguments with default tokens
r del mykey
set result [r gcra mykey 10 5 60]
assert_equal 5 [llength $result]
set limited [lindex $result 0]
set max_req_num [lindex $result 1]
assert_equal $limited 0
assert_equal 11 $max_req_num
# Valid arguments with explicit tokens
r del mykey
set result [r gcra mykey 10 5 60 NUM_REQUESTS 2]
assert_equal 5 [llength $result]
assert_equal 11 [lindex $result 1]
# Period accepts fractional seconds
r del mykey
set result [r gcra mykey 10 5 0.5]
assert_equal 5 [llength $result]
}
test {GCRA - first request is allowed} {
r del mykey
set result [r gcra mykey 5 10 60]
set limited [lindex $result 0]
# First request should not be limited
assert_equal 0 $limited
}
test {GCRA - requests within burst are allowed} {
r del mykey
# max_burst=5, tokens_per_period=1, period=60
# This allows burst of 6 requests (max_burst + 1)
for {set i 0} {$i < 6} {incr i} {
set result [r gcra mykey 5 1 60]
set limited [lindex $result 0]
assert_equal 0 $limited "Request $i should be allowed"
}
# Request 6 should be limited
set result [r gcra mykey 5 1 60]
set limited [lindex $result 0]
assert_equal 1 $limited "Request 6 should be limited"
}
test {GCRA - retry_after is positive when limited} {
r del mykey
# Exhaust burst
for {set i 0} {$i < 3} {incr i} {
r gcra mykey 2 1 60
}
# Next request should be limited with positive retry_after
set result [r gcra mykey 2 1 60]
set limited [lindex $result 0]
set retry_after [lindex $result 3]
assert_equal 1 $limited
assert {$retry_after > 0}
}
test {GCRA - retry_after is negative when allowed} {
r del mykey
set result [r gcra mykey 5 1 60]
set limited [lindex $result 0]
set retry_after [lindex $result 3]
assert_equal 0 $limited
assert {$retry_after < 0}
}
test {GCRA - num_avail_req decreases with each request} {
r del mykey
set result1 [r gcra mykey 5 1 60]
set avail1 [lindex $result1 2]
set result2 [r gcra mykey 5 1 60]
set avail2 [lindex $result2 2]
assert {$avail2 < $avail1}
}
test {GCRA - multiple tokens consumed per request} {
r del mykey
# max_burst=5, so 6 tokens available initially
# Consume 1 token (default)
set result1 [r gcra mykey 5 1 60]
set avail1 [lindex $result1 2]
assert_equal 5 $avail1
r del mykey
# Consume 3 tokens from fresh state
set result2 [r gcra mykey 5 1 60 NUM_REQUESTS 3]
set avail2 [lindex $result2 2]
assert_equal 3 $avail2
}
test {GCRA - rate recovery over time} {
r del mykey
# max_burst=1, tokens_per_period=1, period=1 (1 token per second)
# Exhaust: 2 allowed (burst+1), then limited
r gcra mykey 1 1 1
r gcra mykey 1 1 1
set result [r gcra mykey 1 1 1]
assert_equal 1 [lindex $result 0] "Should be limited"
# Wait for rate to recover
after 1100
# Should be allowed again
set result [r gcra mykey 1 1 1]
assert_equal 0 [lindex $result 0] "Should be allowed after recovery"
}
test {GCRA - full_burst_after indicates time to full recovery} {
r del mykey
# Consume some tokens
r gcra mykey 5 1 60
r gcra mykey 5 1 60
set result [r gcra mykey 5 1 60]
set full_burst_after [lindex $result 4]
# full_burst_after should be positive (time until full burst available)
# Since we've taken from the burst twice the reset time was incremented
# by the rate twice
assert {$full_burst_after >= 179}
r del mykey
r gcra mykey 5 1 60
set result [r gcra mykey 5 1 60 NUM_REQUESTS 4]
set full_burst_after [lindex $result 4]
assert {$full_burst_after >= 299}
}
test {GCRA - different keys are independent} {
r del key1
r del key2
# Exhaust key1
for {set i 0} {$i < 3} {incr i} {
r gcra key1 2 1 60
}
set result1 [r gcra key1 2 1 60]
assert_equal 1 [lindex $result1 0] "key1 should be limited"
# key2 should still be available
set result2 [r gcra key2 2 1 60]
assert_equal 0 [lindex $result2 0] "key2 should be allowed"
}
test {GCRA - max_burst of 0 allows only sustained rate} {
r del mykey
# max_burst=0, tokens_per_period=1, period=1
# Only 1 request allowed initially (0+1)
set result [r gcra mykey 0 1 1]
assert_equal 0 [lindex $result 0] "First request allowed"
assert_equal 1 [lindex $result 1] "max_req_num should be 1"
# Second request should be limited
set result [r gcra mykey 0 1 1]
assert_equal 1 [lindex $result 0] "Second request limited"
}
test {GCRA - wrong type error} {
r del mykey
r lpush mykey "value"
catch {r gcra mykey 5 1 60} err
assert_match "*WRONGTYPE*" $err
r del mykey
r sadd mykey "value"
catch {r gcra mykey 5 1 60} err
assert_match "*WRONGTYPE*" $err
}
test {GCRA - overflow} {
r del mykey
catch {r gcra mykey 1 1 86400 NUM_REQUESTS 200000000} err
assert_match "*would cause an overflow*" $err
r del mykey
catch {r gcra mykey 200000000 1 86400} err
assert_match "*would cause an overflow*" $err
r del mykey
catch {r gcra mykey 1 1 2147483647 NUM_REQUESTS 2147483647} err
assert_match "*would cause an overflow*" $err
}
}
start_server {tags {"gcra repl" "external:skip"}} {
set replica [srv 0 client]
set replica_host [srv 0 host]
set replica_port [srv 0 port]
set replica_log [srv 0 stdout]
start_server {tags {}} {
set master [srv 0 client]
set master_host [srv 0 host]
set master_port [srv 0 port]
$master flushdb
$replica flushdb
$replica replicaof $master_host $master_port
wait_for_condition 100 100 {
[s -1 master_link_status] eq "up"
} else {
fail "Master <-> Replica didn't finish sync"
}
set cmdinfo [$replica info commandstats]
assert_equal [lsearch -glob $cmdinfo "cmdstat_gcra:*"] -1
assert_equal [lsearch -glob $cmdinfo "cmdstat_set:*"] -1
$master del mykey
$master gcra mykey 2 1 1000 NUM_REQUESTS 2
wait_for_ofs_sync $master $replica
set cmdinfo [$replica info commandstats]
assert_equal [lsearch -glob $cmdinfo "cmdstat_gcra:*"] -1
assert_morethan_equal [lsearch -glob $cmdinfo "cmdstat_set:*"] 0
}
}