Skip to content

Commit f4e4bf1

Browse files
authored
feat(client): add GCRA ratelimit command @nkaradzhov (redis#3211)
* feat(client): add GCRA ratelimit command * fix: rename params based on design change * fix: change `limited` to boolean
1 parent 4f15d0f commit f4e4bf1

6 files changed

Lines changed: 179 additions & 1 deletion

File tree

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This folder contains example scripts showing how to use Node Redis in different
1414
| `cuckoo-filter.js` | Space efficient set membership checks with a [Cuckoo Filter](https://en.wikipedia.org/wiki/Cuckoo_filter) using [RedisBloom](https://redisbloom.io). |
1515
| `cas-cad-digest.js` | Atomic compare-and-set (CAS) and compare-and-delete (CAD) using digests for single-key optimistic concurrency control. |
1616
| `dump-and-restore.js` | Demonstrates the use of the [`DUMP`](https://redis.io/commands/dump/) and [`RESTORE`](https://redis.io/commands/restore/) commands |
17+
| `gcra-rate-limiting.js` | Demonstrates the [`GCRA`](https://redis.io/commands/gcra/) command for server-side rate limiting with optional token cost (`TOKENS`). |
1718
| `get-server-time.js` | Get the time from the Redis server. |
1819
| `hyperloglog.js` | Showing use of Hyperloglog commands [PFADD, PFCOUNT and PFMERGE](https://redis.io/commands/?group=hyperloglog). |
1920
| `lua-multi-incr.js` | Define a custom lua script that allows you to perform INCRBY on multiple keys. |

examples/gcra-rate-limiting.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Rate limit requests with the Redis GCRA command (Redis 8.8+).
2+
3+
import { createClient } from 'redis';
4+
5+
const client = createClient();
6+
await client.connect();
7+
8+
const key = 'rate-limit:user:42';
9+
await client.del(key);
10+
11+
const maxBurst = 2;
12+
const tokensPerPeriod = 5;
13+
const periodSeconds = 1;
14+
15+
console.log('Basic rate limiting (5 requests/second with burst=2)');
16+
for (let i = 1; i <= 5; i++) {
17+
const { limited, maxRequests, availableRequests, retryAfter, fullBurstAfter } =
18+
await client.gcra(key, maxBurst, tokensPerPeriod, periodSeconds);
19+
20+
console.log(
21+
`Attempt ${i}: limited=${limited}, max=${maxRequests}, available=${availableRequests}, retryAfter=${retryAfter}, fullBurstAfter=${fullBurstAfter}`
22+
);
23+
}
24+
25+
console.log('\nWeighted request using TOKENS=2');
26+
const weighted = await client.gcra(key, maxBurst, tokensPerPeriod, periodSeconds, 2);
27+
console.log(weighted);
28+
29+
await client.close();
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { strict as assert } from 'node:assert';
2+
import testUtils, { GLOBAL } from '../test-utils';
3+
import GCRA from './GCRA';
4+
import { parseArgs } from './generic-transformers';
5+
6+
describe('GCRA', () => {
7+
testUtils.isVersionGreaterThanHook([8, 8]);
8+
9+
describe('transformArguments', () => {
10+
it('with required arguments', () => {
11+
assert.deepEqual(
12+
parseArgs(GCRA, 'key', 15, 30, 60),
13+
['GCRA', 'key', '15', '30', '60']
14+
);
15+
});
16+
17+
it('with a fractional period', () => {
18+
assert.deepEqual(
19+
parseArgs(GCRA, 'key', 15, 30, 0.5),
20+
['GCRA', 'key', '15', '30', '0.5']
21+
);
22+
});
23+
24+
it('with TOKENS', () => {
25+
assert.deepEqual(
26+
parseArgs(GCRA, 'key', 15, 30, 60, 3),
27+
['GCRA', 'key', '15', '30', '60', 'TOKENS', '3']
28+
);
29+
});
30+
});
31+
32+
function assertReplyShape(reply: {
33+
limited: boolean;
34+
maxRequests: number;
35+
availableRequests: number;
36+
retryAfter: number;
37+
fullBurstAfter: number;
38+
}, expectedMaxRequests: number) {
39+
assert.ok(reply.limited === true || reply.limited === false);
40+
assert.equal(reply.maxRequests, expectedMaxRequests);
41+
assert.ok(reply.availableRequests >= 0);
42+
assert.ok(reply.retryAfter >= -1);
43+
assert.ok(reply.fullBurstAfter >= 0);
44+
}
45+
46+
testUtils.testWithClient('gcra allows one request then limits the next with zero burst', async client => {
47+
const first = await client.gcra('gcra:single-token', 0, 1, 1);
48+
const second = await client.gcra('gcra:single-token', 0, 1, 1);
49+
50+
assertReplyShape(first, 1);
51+
assertReplyShape(second, 1);
52+
assert.notEqual(first.limited, second.limited);
53+
54+
assert.ok(first.retryAfter === -1 || second.retryAfter === -1);
55+
assert.ok(first.retryAfter >= 0 || second.retryAfter >= 0);
56+
}, GLOBAL.SERVERS.OPEN);
57+
58+
testUtils.testWithClient('gcra supports weighted requests using TOKENS', async client => {
59+
const key = 'gcra:weighted';
60+
61+
const first = await client.gcra(key, 10, 10, 1, 10);
62+
const second = await client.gcra(key, 10, 10, 1, 10);
63+
64+
assertReplyShape(first, 11);
65+
assertReplyShape(second, 11);
66+
assert.notEqual(first.limited, second.limited);
67+
}, GLOBAL.SERVERS.OPEN);
68+
69+
testUtils.testWithClient('gcra returns the same reply shape on RESP3', async client => {
70+
const first = await client.gcra('gcra:resp3', 0, 1, 1);
71+
const second = await client.gcra('gcra:resp3', 0, 1, 1);
72+
73+
assertReplyShape(first, 1);
74+
assertReplyShape(second, 1);
75+
assert.notEqual(first.limited, second.limited);
76+
}, GLOBAL.SERVERS.OPEN_RESP_3);
77+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { CommandParser } from '../client/parser';
2+
import { BooleanReply, Command, NumberReply, RedisArgument, TuplesReply, UnwrapReply } from '../RESP/types';
3+
import { transformDoubleArgument } from './generic-transformers';
4+
5+
export type GCRARawReply = TuplesReply<[
6+
limited: NumberReply<0 | 1>,
7+
maxRequests: NumberReply,
8+
availableRequests: NumberReply,
9+
retryAfter: NumberReply,
10+
fullBurstAfter: NumberReply
11+
]>;
12+
13+
export interface GCRAReply {
14+
limited: BooleanReply;
15+
maxRequests: NumberReply;
16+
availableRequests: NumberReply;
17+
retryAfter: NumberReply;
18+
fullBurstAfter: NumberReply;
19+
}
20+
21+
function transformGCRAReply(reply: UnwrapReply<GCRARawReply>): GCRAReply {
22+
return {
23+
limited: (reply[0] as unknown as number === 1) as unknown as BooleanReply,
24+
maxRequests: reply[1],
25+
availableRequests: reply[2],
26+
retryAfter: reply[3],
27+
fullBurstAfter: reply[4]
28+
};
29+
}
30+
31+
export default {
32+
IS_READ_ONLY: false,
33+
/**
34+
* Rate limit via GCRA (Generic Cell Rate Algorithm).
35+
* `tokensPerPeriod` are allowed per `period` at a sustained rate, which implies
36+
* a minimum emission interval of `period / tokensPerPeriod` seconds between requests.
37+
* `maxBurst` allows occasional spikes by permitting up to `maxBurst` additional
38+
* tokens to be consumed at once.
39+
* @param parser - The Redis command parser
40+
* @param key - Key associated with the rate limit bucket
41+
* @param maxBurst - Maximum number of extra tokens allowed as burst (min 0)
42+
* @param tokensPerPeriod - Number of tokens allowed per period (min 1)
43+
* @param period - Period in seconds as a float for sustained rate calculation (min 1.0, max 1e12)
44+
* @param tokens - Optional request cost (weight). If omitted, defaults to 1
45+
* @see https://redis.io/commands/gcra/
46+
*/
47+
parseCommand(
48+
parser: CommandParser,
49+
key: RedisArgument,
50+
maxBurst: number,
51+
tokensPerPeriod: number,
52+
period: number,
53+
tokens?: number
54+
) {
55+
parser.push('GCRA');
56+
parser.pushKey(key);
57+
parser.push(
58+
maxBurst.toString(),
59+
tokensPerPeriod.toString(),
60+
transformDoubleArgument(period)
61+
);
62+
63+
if (tokens !== undefined) {
64+
parser.push('TOKENS', tokens.toString());
65+
}
66+
},
67+
transformReply: transformGCRAReply
68+
} as const satisfies Command;

packages/client/lib/commands/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import EVAL_RO from './EVAL_RO';
9292
import EVAL from './EVAL';
9393
import EVALSHA_RO from './EVALSHA_RO';
9494
import EVALSHA from './EVALSHA';
95+
import GCRA from './GCRA';
9596
import GEOADD from './GEOADD';
9697
import GEODIST from './GEODIST';
9798
import GEOHASH from './GEOHASH';
@@ -603,6 +604,8 @@ export default {
603604
functionRestore: FUNCTION_RESTORE,
604605
FUNCTION_STATS,
605606
functionStats: FUNCTION_STATS,
607+
GCRA,
608+
gcra: GCRA,
606609
GEOADD,
607610
geoAdd: GEOADD,
608611
GEODIST,

packages/client/lib/test-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export const GLOBAL = {
9797
OPEN_RESP_3: {
9898
serverArguments: [...DEBUG_MODE_ARGS],
9999
clientOptions: {
100-
RESP: 3,
100+
RESP: 3 as const,
101101
}
102102
},
103103
ASYNC_BASIC_AUTH: {

0 commit comments

Comments
 (0)