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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This folder contains example scripts showing how to use Node Redis in different
| `cuckoo-filter.js` | Space efficient set membership checks with a [Cuckoo Filter](https://en.wikipedia.org/wiki/Cuckoo_filter) using [RedisBloom](https://redisbloom.io). |
| `cas-cad-digest.js` | Atomic compare-and-set (CAS) and compare-and-delete (CAD) using digests for single-key optimistic concurrency control. |
| `dump-and-restore.js` | Demonstrates the use of the [`DUMP`](https://redis.io/commands/dump/) and [`RESTORE`](https://redis.io/commands/restore/) commands |
| `gcra-rate-limiting.js` | Demonstrates the [`GCRA`](https://redis.io/commands/gcra/) command for server-side rate limiting with optional token cost (`TOKENS`). |
| `get-server-time.js` | Get the time from the Redis server. |
| `hyperloglog.js` | Showing use of Hyperloglog commands [PFADD, PFCOUNT and PFMERGE](https://redis.io/commands/?group=hyperloglog). |
| `lua-multi-incr.js` | Define a custom lua script that allows you to perform INCRBY on multiple keys. |
Expand Down
29 changes: 29 additions & 0 deletions examples/gcra-rate-limiting.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Rate limit requests with the Redis GCRA command (Redis 8.8+).

import { createClient } from 'redis';

const client = createClient();
await client.connect();

const key = 'rate-limit:user:42';
await client.del(key);

const maxBurst = 2;
const tokensPerPeriod = 5;
const periodSeconds = 1;

console.log('Basic rate limiting (5 requests/second with burst=2)');
for (let i = 1; i <= 5; i++) {
const { limited, maxRequests, availableRequests, retryAfter, fullBurstAfter } =
await client.gcra(key, maxBurst, tokensPerPeriod, periodSeconds);

console.log(
`Attempt ${i}: limited=${limited}, max=${maxRequests}, available=${availableRequests}, retryAfter=${retryAfter}, fullBurstAfter=${fullBurstAfter}`
);
}

console.log('\nWeighted request using TOKENS=2');
const weighted = await client.gcra(key, maxBurst, tokensPerPeriod, periodSeconds, 2);
console.log(weighted);

await client.close();
77 changes: 77 additions & 0 deletions packages/client/lib/commands/GCRA.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import GCRA from './GCRA';
import { parseArgs } from './generic-transformers';

describe('GCRA', () => {
testUtils.isVersionGreaterThanHook([8, 8]);

describe('transformArguments', () => {
it('with required arguments', () => {
assert.deepEqual(
parseArgs(GCRA, 'key', 15, 30, 60),
['GCRA', 'key', '15', '30', '60']
);
});

it('with a fractional period', () => {
assert.deepEqual(
parseArgs(GCRA, 'key', 15, 30, 0.5),
['GCRA', 'key', '15', '30', '0.5']
);
});

it('with TOKENS', () => {
assert.deepEqual(
parseArgs(GCRA, 'key', 15, 30, 60, 3),
['GCRA', 'key', '15', '30', '60', 'TOKENS', '3']
);
});
});

function assertReplyShape(reply: {
limited: boolean;
maxRequests: number;
availableRequests: number;
retryAfter: number;
fullBurstAfter: number;
}, expectedMaxRequests: number) {
assert.ok(reply.limited === true || reply.limited === false);
assert.equal(reply.maxRequests, expectedMaxRequests);
assert.ok(reply.availableRequests >= 0);
assert.ok(reply.retryAfter >= -1);
assert.ok(reply.fullBurstAfter >= 0);
}

testUtils.testWithClient('gcra allows one request then limits the next with zero burst', async client => {
const first = await client.gcra('gcra:single-token', 0, 1, 1);
const second = await client.gcra('gcra:single-token', 0, 1, 1);

assertReplyShape(first, 1);
assertReplyShape(second, 1);
assert.notEqual(first.limited, second.limited);

assert.ok(first.retryAfter === -1 || second.retryAfter === -1);
assert.ok(first.retryAfter >= 0 || second.retryAfter >= 0);
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClient('gcra supports weighted requests using TOKENS', async client => {
const key = 'gcra:weighted';

const first = await client.gcra(key, 10, 10, 1, 10);
const second = await client.gcra(key, 10, 10, 1, 10);

assertReplyShape(first, 11);
assertReplyShape(second, 11);
assert.notEqual(first.limited, second.limited);
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClient('gcra returns the same reply shape on RESP3', async client => {
const first = await client.gcra('gcra:resp3', 0, 1, 1);
const second = await client.gcra('gcra:resp3', 0, 1, 1);

assertReplyShape(first, 1);
assertReplyShape(second, 1);
assert.notEqual(first.limited, second.limited);
}, GLOBAL.SERVERS.OPEN_RESP_3);
});
68 changes: 68 additions & 0 deletions packages/client/lib/commands/GCRA.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { CommandParser } from '../client/parser';
import { BooleanReply, Command, NumberReply, RedisArgument, TuplesReply, UnwrapReply } from '../RESP/types';
import { transformDoubleArgument } from './generic-transformers';

export type GCRARawReply = TuplesReply<[
limited: NumberReply<0 | 1>,
maxRequests: NumberReply,
availableRequests: NumberReply,
retryAfter: NumberReply,
fullBurstAfter: NumberReply
]>;

export interface GCRAReply {
limited: BooleanReply;
maxRequests: NumberReply;
availableRequests: NumberReply;
retryAfter: NumberReply;
fullBurstAfter: NumberReply;
}

function transformGCRAReply(reply: UnwrapReply<GCRARawReply>): GCRAReply {
return {
limited: (reply[0] as unknown as number === 1) as unknown as BooleanReply,
maxRequests: reply[1],
availableRequests: reply[2],
retryAfter: reply[3],
fullBurstAfter: reply[4]
};
}

export default {
IS_READ_ONLY: false,
/**
* Rate limit via GCRA (Generic Cell Rate Algorithm).
* `tokensPerPeriod` are allowed per `period` at a sustained rate, which implies
* a minimum emission interval of `period / tokensPerPeriod` seconds between requests.
* `maxBurst` allows occasional spikes by permitting up to `maxBurst` additional
* tokens to be consumed at once.
* @param parser - The Redis command parser
* @param key - Key associated with the rate limit bucket
* @param maxBurst - Maximum number of extra tokens allowed as burst (min 0)
* @param tokensPerPeriod - Number of tokens allowed per period (min 1)
* @param period - Period in seconds as a float for sustained rate calculation (min 1.0, max 1e12)
* @param tokens - Optional request cost (weight). If omitted, defaults to 1
* @see https://redis.io/commands/gcra/
*/
parseCommand(
parser: CommandParser,
key: RedisArgument,
maxBurst: number,
tokensPerPeriod: number,
period: number,
tokens?: number
) {
parser.push('GCRA');
parser.pushKey(key);
parser.push(
maxBurst.toString(),
tokensPerPeriod.toString(),
transformDoubleArgument(period)
);

if (tokens !== undefined) {
parser.push('TOKENS', tokens.toString());
}
},
transformReply: transformGCRAReply
} as const satisfies Command;
3 changes: 3 additions & 0 deletions packages/client/lib/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import EVAL_RO from './EVAL_RO';
import EVAL from './EVAL';
import EVALSHA_RO from './EVALSHA_RO';
import EVALSHA from './EVALSHA';
import GCRA from './GCRA';
import GEOADD from './GEOADD';
import GEODIST from './GEODIST';
import GEOHASH from './GEOHASH';
Expand Down Expand Up @@ -603,6 +604,8 @@ export default {
functionRestore: FUNCTION_RESTORE,
FUNCTION_STATS,
functionStats: FUNCTION_STATS,
GCRA,
gcra: GCRA,
GEOADD,
geoAdd: GEOADD,
GEODIST,
Expand Down
2 changes: 1 addition & 1 deletion packages/client/lib/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export const GLOBAL = {
OPEN_RESP_3: {
serverArguments: [...DEBUG_MODE_ARGS],
clientOptions: {
RESP: 3,
RESP: 3 as const,
}
},
ASYNC_BASIC_AUTH: {
Expand Down
Loading