From bda72f57b5b0d3af25d2710a22557c6a74036094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Blaznik?= Date: Sun, 23 Mar 2025 17:08:05 +0100 Subject: [PATCH 1/4] feat: node-redis --- index.js | 8 +- package.json | 3 +- store/NodeRedisStore.js | 52 +++ store/RedisStore.js | 1 + test/node-redis-rate-limit.test.js | 624 +++++++++++++++++++++++++++++ 5 files changed, 686 insertions(+), 2 deletions(-) create mode 100644 store/NodeRedisStore.js create mode 100644 test/node-redis-rate-limit.test.js diff --git a/index.js b/index.js index 050172a..86c3716 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,7 @@ const { parse, format } = require('@lukeed/ms') const LocalStore = require('./store/LocalStore') const RedisStore = require('./store/RedisStore') +const NodeRedisStore = require('./store/NodeRedisStore') const defaultMax = 1000 const defaultTimeWindow = 60000 @@ -117,7 +118,11 @@ async function fastifyRateLimit (fastify, settings) { pluginComponent.store = new Store(globalParams) } else { if (settings.redis) { - pluginComponent.store = new RedisStore(globalParams.continueExceeding, globalParams.exponentialBackoff, settings.redis, settings.nameSpace) + if (settings.redis.constructor.name === 'Commander') { + pluginComponent.store = new NodeRedisStore(globalParams.continueExceeding, globalParams.exponentialBackoff, settings.redis, settings.nameSpace) + } else { + pluginComponent.store = new RedisStore(globalParams.continueExceeding, globalParams.exponentialBackoff, settings.redis, settings.nameSpace) + } } else { pluginComponent.store = new LocalStore(globalParams.continueExceeding, globalParams.exponentialBackoff, settings.cache) } @@ -290,3 +295,4 @@ module.exports = fp(fastifyRateLimit, { }) module.exports.default = fastifyRateLimit module.exports.fastifyRateLimit = fastifyRateLimit +module.exports.NodeRedisStore = NodeRedisStore diff --git a/package.json b/package.json index 29c04a9..b4dc19a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint:fix": "eslint --fix", "redis": "docker run -p 6379:6379 --name rate-limit-redis -d --rm redis", "test": "npm run test:unit && npm run test:typescript", - "test:unit": "c8 --100 node --test", + "test:unit": "c8 --100 node --test --test-concurrency=1", "test:typescript": "tsd" }, "repository": { @@ -60,6 +60,7 @@ ], "devDependencies": { "@fastify/pre-commit": "^2.1.0", + "@redis/client": "^1.6.0", "@sinonjs/fake-timers": "^14.0.0", "@types/node": "^22.0.0", "c8": "^10.1.2", diff --git a/store/NodeRedisStore.js b/store/NodeRedisStore.js new file mode 100644 index 0000000..ccd8b0e --- /dev/null +++ b/store/NodeRedisStore.js @@ -0,0 +1,52 @@ +'use strict' + +/** + * When using node-redis, you need to initialize the client with the rateLimit script like this: + * ```js + * const redis = createClient({ + * scripts: { + * rateLimit: rateLimit.NodeRedisStore.rateLimitScript + * } + * }); + * ``` + */ + +const { lua } = require('./RedisStore') +const { defineScript } = require('@redis/client') + +const rateLimitScript = defineScript({ + NUMBER_OF_KEYS: 1, + SCRIPT: lua, + transformArguments (key, timeWindow, max, continueExceeding, exponentialBackoff) { + return [key, timeWindow.toString(), max.toString(), continueExceeding.toString(), exponentialBackoff.toString()] + }, + transformReply (reply) { + return reply + }, +}) + +function NodeRedisStore (continueExceeding, exponentialBackoff, redis, key = 'fastify-rate-limit-') { + this.continueExceeding = continueExceeding + this.exponentialBackoff = exponentialBackoff + this.redis = redis + this.key = key +} + +NodeRedisStore.prototype.incr = function (ip, cb, timeWindow, max) { + this + .redis + .rateLimit(this.key + ip, timeWindow, max, this.continueExceeding, this.exponentialBackoff) + .then(result => { + cb(null, { current: result[0], ttl: result[1] }) + }) + .catch(err => { + cb(err, null) + }) +} + +NodeRedisStore.prototype.child = function (routeOptions) { + return new NodeRedisStore(routeOptions.continueExceeding, routeOptions.exponentialBackoff, this.redis, `${this.key}${routeOptions.routeInfo.method}${routeOptions.routeInfo.url}-`) +} + +module.exports = NodeRedisStore +module.exports.rateLimitScript = rateLimitScript diff --git a/store/RedisStore.js b/store/RedisStore.js index 5a1aab6..e1479b3 100644 --- a/store/RedisStore.js +++ b/store/RedisStore.js @@ -62,3 +62,4 @@ RedisStore.prototype.child = function (routeOptions) { } module.exports = RedisStore +module.exports.lua = lua diff --git a/test/node-redis-rate-limit.test.js b/test/node-redis-rate-limit.test.js new file mode 100644 index 0000000..15fff06 --- /dev/null +++ b/test/node-redis-rate-limit.test.js @@ -0,0 +1,624 @@ +'use strict' + +const { test, describe, beforeEach } = require('node:test') +const Fastify = require('fastify') +const rateLimit = require('../index') +const { createClient } = require('@redis/client') + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const REDIS_HOST = 'redis://127.0.0.1' + +describe('Global rate limit', () => { + let redis + + beforeEach(async () => { + redis = createClient({ + url: REDIS_HOST, + scripts: { + rateLimit: rateLimit.NodeRedisStore.rateLimitScript + } + }) + await redis.connect() + }) + + test('With redis store', async (t) => { + t.plan(21) + const fastify = Fastify() + await fastify.register(rateLimit, { + max: 2, + timeWindow: 1000, + redis + }) + + fastify.get('/', async () => 'hello!') + + let res + + res = await fastify.inject('/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.ok(res) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + + await sleep(100) + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 429) + t.assert.deepStrictEqual( + res.headers['content-type'], + 'application/json; charset=utf-8' + ) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + t.assert.deepStrictEqual(res.headers['retry-after'], '1') + t.assert.deepStrictEqual( + { + statusCode: 429, + error: 'Too Many Requests', + message: 'Rate limit exceeded, retry in 1 second' + }, + JSON.parse(res.payload) + ) + + // Not using fake timers here as we use an external Redis that would not be effected by this + await sleep(1100) + + res = await fastify.inject('/') + + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + + await redis.flushAll() + await redis.quit() + }) + + test('With redis store (ban)', async (t) => { + t.plan(19) + const fastify = Fastify() + await fastify.register(rateLimit, { + max: 1, + ban: 1, + timeWindow: 1000, + redis + }) + + fastify.get('/', async () => 'hello!') + + let res + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 429) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 403) + t.assert.deepStrictEqual( + res.headers['content-type'], + 'application/json; charset=utf-8' + ) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + t.assert.deepStrictEqual(res.headers['retry-after'], '1') + t.assert.deepStrictEqual( + { + statusCode: 403, + error: 'Forbidden', + message: 'Rate limit exceeded, retry in 1 second' + }, + JSON.parse(res.payload) + ) + + // Not using fake timers here as we use an external Redis that would not be effected by this + await sleep(1100) + + res = await fastify.inject('/') + + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '1') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + + await redis.flushAll() + await redis.quit() + }) + + test('Skip on redis error', async (t) => { + t.plan(9) + const fastify = Fastify() + await fastify.register(rateLimit, { + max: 2, + timeWindow: 1000, + redis, + skipOnError: true + }) + + fastify.get('/', async () => 'hello!') + + let res + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1') + + await redis.flushAll() + await redis.quit() + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2') + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2') + }) + + test('Throw on redis error', async (t) => { + t.plan(5) + const fastify = Fastify() + await fastify.register(rateLimit, { + max: 2, + timeWindow: 1000, + redis, + skipOnError: false + }) + + fastify.get('/', async () => 'hello!') + + let res + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1') + + await redis.flushAll() + await redis.quit() + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 500) + t.assert.deepStrictEqual( + res.body, + '{"statusCode":500,"error":"Internal Server Error","message":"The client is closed"}' + ) + }) + + test('When continue exceeding is on (Redis)', async (t) => { + const fastify = Fastify() + + await fastify.register(rateLimit, { + redis, + max: 1, + timeWindow: 5000, + continueExceeding: true + }) + + fastify.get('/', async () => 'hello!') + + const first = await fastify.inject({ + url: '/', + method: 'GET' + }) + const second = await fastify.inject({ + url: '/', + method: 'GET' + }) + + t.assert.deepStrictEqual(first.statusCode, 200) + + t.assert.deepStrictEqual(second.statusCode, 429) + t.assert.deepStrictEqual(second.headers['x-ratelimit-limit'], '1') + t.assert.deepStrictEqual(second.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(second.headers['x-ratelimit-reset'], '5') + + await redis.flushAll() + await redis.quit() + }) + + test('Redis with continueExceeding should not always return the timeWindow as ttl', async (t) => { + t.plan(19) + const fastify = Fastify() + await fastify.register(rateLimit, { + max: 2, + timeWindow: 3000, + continueExceeding: true, + redis + }) + + fastify.get('/', async () => 'hello!') + + let res + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '3') + + // After this sleep, we should not see `x-ratelimit-reset === 3` anymore + await sleep(1000) + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '2') + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 429) + t.assert.deepStrictEqual( + res.headers['content-type'], + 'application/json; charset=utf-8' + ) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '3') + t.assert.deepStrictEqual(res.headers['retry-after'], '3') + t.assert.deepStrictEqual( + { + statusCode: 429, + error: 'Too Many Requests', + message: 'Rate limit exceeded, retry in 3 seconds' + }, + JSON.parse(res.payload) + ) + + // Not using fake timers here as we use an external Redis that would not be effected by this + await sleep(1000) + + res = await fastify.inject('/') + + t.assert.deepStrictEqual(res.statusCode, 429) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '3') + + await redis.flushAll() + await redis.quit() + }) + + test('When use a custom nameSpace', async (t) => { + const fastify = Fastify() + + await fastify.register(rateLimit, { + max: 2, + timeWindow: 1000, + redis, + nameSpace: 'my-namespace:', + keyGenerator: (req) => req.headers['x-my-header'] + }) + + fastify.get('/', async () => 'hello!') + + const allowListHeader = { + method: 'GET', + url: '/', + headers: { + 'x-my-header': 'custom name space' + } + } + + let res + + res = await fastify.inject(allowListHeader) + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + + res = await fastify.inject(allowListHeader) + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + + res = await fastify.inject(allowListHeader) + t.assert.deepStrictEqual(res.statusCode, 429) + t.assert.deepStrictEqual( + res.headers['content-type'], + 'application/json; charset=utf-8' + ) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + t.assert.deepStrictEqual(res.headers['retry-after'], '1') + t.assert.deepStrictEqual( + { + statusCode: 429, + error: 'Too Many Requests', + message: 'Rate limit exceeded, retry in 1 second' + }, + JSON.parse(res.payload) + ) + + // Not using fake timers here as we use an external Redis that would not be effected by this + await sleep(1100) + + res = await fastify.inject(allowListHeader) + + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + + await redis.flushAll() + await redis.quit() + }) +}) + +describe('Route rate limit', () => { + let redis + + beforeEach(async () => { + redis = createClient({ + scripts: { + rateLimit: rateLimit.NodeRedisStore.rateLimitScript + } + }) + await redis.connect() + }) + + test('With redis store', async t => { + t.plan(19) + const fastify = Fastify() + await fastify.register(rateLimit, { + global: false, + redis + }) + + fastify.get('/', { + config: { + rateLimit: { + max: 2, + timeWindow: 1000 + }, + someOtherPlugin: { + someValue: 1 + } + } + }, async () => 'hello!') + + let res + + res = await fastify.inject('/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '1') + t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1') + + res = await fastify.inject('/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1') + + res = await fastify.inject('/') + t.assert.strictEqual(res.statusCode, 429) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '0') + t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1') + t.assert.strictEqual(res.headers['retry-after'], '1') + t.assert.deepStrictEqual({ + statusCode: 429, + error: 'Too Many Requests', + message: 'Rate limit exceeded, retry in 1 second' + }, JSON.parse(res.payload)) + + // Not using fake timers here as we use an external Redis that would not be effected by this + await sleep(1100) + + res = await fastify.inject('/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.strictEqual(res.headers['x-ratelimit-remaining'], '1') + t.assert.strictEqual(res.headers['x-ratelimit-reset'], '1') + + await redis.flushAll() + await redis.quit() + }) + + test('Throw on redis error', async (t) => { + t.plan(6) + const fastify = Fastify() + await fastify.register(rateLimit, { + redis, + global: false + }) + + fastify.get( + '/', + { + config: { + rateLimit: { + max: 2, + timeWindow: 1000, + skipOnError: false + } + } + }, + async () => 'hello!' + ) + + let res + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1') + t.assert.deepStrictEqual(res.headers['x-ratelimit-reset'], '1') + + await redis.flushAll() + await redis.quit() + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 500) + t.assert.deepStrictEqual( + res.body, + '{"statusCode":500,"error":"Internal Server Error","message":"The client is closed"}' + ) + }) + + test('Skip on redis error', async (t) => { + t.plan(9) + const fastify = Fastify() + await fastify.register(rateLimit, { + redis, + global: false + }) + + fastify.get( + '/', + { + config: { + rateLimit: { + max: 2, + timeWindow: 1000, + skipOnError: true + } + } + }, + async () => 'hello!' + ) + + let res + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '1') + + await redis.flushAll() + await redis.quit() + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2') + + res = await fastify.inject('/') + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.headers['x-ratelimit-limit'], '2') + t.assert.deepStrictEqual(res.headers['x-ratelimit-remaining'], '2') + }) + + test('When continue exceeding is on (Redis)', async (t) => { + const fastify = Fastify() + + await fastify.register(rateLimit, { + global: false, + redis + }) + + fastify.get( + '/', + { + config: { + rateLimit: { + timeWindow: 5000, + max: 1, + continueExceeding: true + } + } + }, + async () => 'hello!' + ) + + const first = await fastify.inject({ + url: '/', + method: 'GET' + }) + const second = await fastify.inject({ + url: '/', + method: 'GET' + }) + + t.assert.deepStrictEqual(first.statusCode, 200) + + t.assert.deepStrictEqual(second.statusCode, 429) + t.assert.deepStrictEqual(second.headers['x-ratelimit-limit'], '1') + t.assert.deepStrictEqual(second.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(second.headers['x-ratelimit-reset'], '5') + + await redis.flushAll() + await redis.quit() + }) + + test('When continue exceeding is off under route (Redis)', async (t) => { + const fastify = Fastify() + + await fastify.register(rateLimit, { + global: false, + continueExceeding: true, + redis + }) + + fastify.get( + '/', + { + config: { + rateLimit: { + timeWindow: 5000, + max: 1, + continueExceeding: false + } + } + }, + async () => 'hello!' + ) + + const first = await fastify.inject({ + url: '/', + method: 'GET' + }) + const second = await fastify.inject({ + url: '/', + method: 'GET' + }) + + await sleep(2000) + + const third = await fastify.inject({ + url: '/', + method: 'GET' + }) + + t.assert.deepStrictEqual(first.statusCode, 200) + + t.assert.deepStrictEqual(second.statusCode, 429) + t.assert.deepStrictEqual(second.headers['x-ratelimit-limit'], '1') + t.assert.deepStrictEqual(second.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(second.headers['x-ratelimit-reset'], '5') + + t.assert.deepStrictEqual(third.statusCode, 429) + t.assert.deepStrictEqual(third.headers['x-ratelimit-limit'], '1') + t.assert.deepStrictEqual(third.headers['x-ratelimit-remaining'], '0') + t.assert.deepStrictEqual(third.headers['x-ratelimit-reset'], '3') + + await redis.flushAll() + await redis.quit() + }) +}) From 1a7e80154057f81f638e08ade46f9c3d13d962be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Blaznik?= Date: Sun, 23 Mar 2025 17:40:46 +0100 Subject: [PATCH 2/4] tests: use db=1 to avoid conflicts with ioredis tests --- package.json | 2 +- test/node-redis-rate-limit.test.js | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index b4dc19a..a95ad91 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint:fix": "eslint --fix", "redis": "docker run -p 6379:6379 --name rate-limit-redis -d --rm redis", "test": "npm run test:unit && npm run test:typescript", - "test:unit": "c8 --100 node --test --test-concurrency=1", + "test:unit": "c8 --100 node --test", "test:typescript": "tsd" }, "repository": { diff --git a/test/node-redis-rate-limit.test.js b/test/node-redis-rate-limit.test.js index 15fff06..54df870 100644 --- a/test/node-redis-rate-limit.test.js +++ b/test/node-redis-rate-limit.test.js @@ -7,9 +7,10 @@ const { createClient } = require('@redis/client') const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) -const REDIS_HOST = 'redis://127.0.0.1' +// Use Redis database 1 to avoid conflicts with ioredis test suite +const REDIS_HOST = 'redis://127.0.0.1:6379/1' -describe('Global rate limit', () => { +describe('Global rate limit (node-redis)', () => { let redis beforeEach(async () => { @@ -369,11 +370,12 @@ describe('Global rate limit', () => { }) }) -describe('Route rate limit', () => { +describe('node-redis: Route rate limit (node-redis)', () => { let redis beforeEach(async () => { redis = createClient({ + url: REDIS_HOST, scripts: { rateLimit: rateLimit.NodeRedisStore.rateLimitScript } From c12158455d3fa02f30f33789efd07798a150dbdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Blaznik?= Date: Sun, 23 Mar 2025 17:44:12 +0100 Subject: [PATCH 3/4] fix: avoid potential V8 deopt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gürgün Dayıoğlu Signed-off-by: Aleš Blaznik --- store/NodeRedisStore.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/store/NodeRedisStore.js b/store/NodeRedisStore.js index ccd8b0e..73666e3 100644 --- a/store/NodeRedisStore.js +++ b/store/NodeRedisStore.js @@ -18,7 +18,7 @@ const rateLimitScript = defineScript({ NUMBER_OF_KEYS: 1, SCRIPT: lua, transformArguments (key, timeWindow, max, continueExceeding, exponentialBackoff) { - return [key, timeWindow.toString(), max.toString(), continueExceeding.toString(), exponentialBackoff.toString()] + return [key, String(timeWindow), String(max), String(continueExceeding), String(exponentialBackoff)] }, transformReply (reply) { return reply From c88adcaf0487cf2736590683f424a67aeaabc39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Blaznik?= Date: Sun, 23 Mar 2025 18:25:08 +0100 Subject: [PATCH 4/4] chore: be helpful when client created without rateLimit script --- store/NodeRedisStore.js | 7 +++++++ test/node-redis-rate-limit.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/store/NodeRedisStore.js b/store/NodeRedisStore.js index 73666e3..d854cc7 100644 --- a/store/NodeRedisStore.js +++ b/store/NodeRedisStore.js @@ -30,6 +30,13 @@ function NodeRedisStore (continueExceeding, exponentialBackoff, redis, key = 'fa this.exponentialBackoff = exponentialBackoff this.redis = redis this.key = key + + if (!this.redis.rateLimit) { + throw new Error( + 'rateLimit script missing on Redis instance. Add it when creating client: ' + + 'const redis = createClient({ scripts: { rateLimit: rateLimit.NodeRedisStore.rateLimitScript }})' + ) + } } NodeRedisStore.prototype.incr = function (ip, cb, timeWindow, max) { diff --git a/test/node-redis-rate-limit.test.js b/test/node-redis-rate-limit.test.js index 54df870..d40ac8a 100644 --- a/test/node-redis-rate-limit.test.js +++ b/test/node-redis-rate-limit.test.js @@ -10,6 +10,33 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) // Use Redis database 1 to avoid conflicts with ioredis test suite const REDIS_HOST = 'redis://127.0.0.1:6379/1' +describe('NodeRedisStore initialization', () => { + test('Throw useful error when redis initialised without rateLimit script', async (t) => { + const redis = createClient({ + url: REDIS_HOST, + scripts: { + // Missing rateLimit + } + }) + await redis.connect() + + const fastify = Fastify() + + try { + await fastify.register(rateLimit, { + max: 2, + timeWindow: 1000, + redis + }) + t.assert(false, 'This statements should not be reached') + } catch (e) { + t.assert.match(e.message, /rateLimit script missing/) + } finally { + await redis.quit() + } + }) +}) + describe('Global rate limit (node-redis)', () => { let redis