diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..8ef13be --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +foreground-scripts=true +ignore-scripts=true diff --git a/config/facs/redis.config.json b/config/facs/redis.config.json new file mode 100644 index 0000000..75aa3f7 --- /dev/null +++ b/config/facs/redis.config.json @@ -0,0 +1,14 @@ +{ + "sk0": { + "host": "127.0.0.1", + "port": 6379 + }, + "uc0": { + "host": "127.0.0.1", + "port": 6379 + }, + "test": { + "host": "127.0.0.1", + "port": 6380 + } +} diff --git a/libs/redis.rate.limiter.js b/libs/redis.rate.limiter.js new file mode 100644 index 0000000..6cbe9e2 --- /dev/null +++ b/libs/redis.rate.limiter.js @@ -0,0 +1,55 @@ +'use strict' + +const crypto = require('crypto') +const { GrcUserError, GrcGenericError } = require('@bitfinex/lib-util-err-js') + +const RATE_LIMIT_LUA = ` + local attempts = redis.call('INCR', KEYS[1]) + if attempts == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end + return attempts + ` + +class RedisRateLimiterUtil { + static register ({ redis, command, keyPrefix, logError }) { + return new RedisRateLimiterUtil({ redis, command, keyPrefix, logError })._register() + } + + constructor ({ redis, command, keyPrefix, logError }) { + this._redis = redis + this._command = command + this._keyPrefix = keyPrefix + this._logError = logError + this._registered = false + } + + async checkRateLimit (key, expiry, maxAttempts) { + if (!this._registered) return this._logError(new GrcGenericError('RedisRateLimiter not registered')) + const attempts = await this._redis[this._command](`${this._keyPrefix}:${key}`, expiry) + if (attempts > maxAttempts) { + throw new GrcUserError('ERR_RATE_LIMIT_EXCEEDED') + } + } + + _register () { + const md5 = crypto.createHash('md5').update(RATE_LIMIT_LUA, 'utf8').digest('hex') + const commandSymbol = Symbol.for(this._command) + if (this._redis[this._command] || this._redis[commandSymbol] === md5) return this + try { + this._redis.defineCommand(this._command, { + numberOfKeys: 1, + lua: RATE_LIMIT_LUA + }) + this._redis[commandSymbol] = md5 + this._registered = true + } catch (error) { + this._logError(error) + } + return this + } +} + +module.exports = { + RedisRateLimiterUtil +} diff --git a/package.json b/package.json index ec35eda..8b4ebe4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitfinex/bfx-facs-redis", - "version": "1.0.0", + "version": "1.0.1", "private": false, "description": "Bitfinex Redis Facility", "author": { @@ -13,6 +13,7 @@ ], "dependencies": { "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", + "@bitfinex/lib-util-err-js": "git+https://github.com/bitfinexcom/lib-util-err-js.git", "async": "^3.2.1", "ioredis": "^5.3.0", "lodash": "^4.17.21" @@ -30,5 +31,14 @@ "name": "prdn", "email": "paolo@bitfinex.com" } - ] + ], + "devDependencies": { + "chai": "4.5.0", + "chai-as-promised": "7.1.2", + "mocha": "10.8.2", + "standard": "17.1.2" + }, + "scripts": { + "test": "NODE_ENV=test mocha -R spec -b --recursive --timeout 10000 test" + } } diff --git a/test/itgr/redis.rate.limiter.itgr.test.js b/test/itgr/redis.rate.limiter.itgr.test.js new file mode 100644 index 0000000..7f5f6b3 --- /dev/null +++ b/test/itgr/redis.rate.limiter.itgr.test.js @@ -0,0 +1,52 @@ +/* eslint-env mocha */ + +'use strict' + +const chai = require('chai') +const chaiAsPromised = require('chai-as-promised') +chai.use(chaiAsPromised) + +const { expect } = require('chai') +const RedisFacility = require('../../index') +const { RedisRateLimiterUtil } = require('../../libs/redis.rate.limiter') + +describe('Redis Rate Limiter Integration', () => { + let redisFacility, redis, redisRateLimiter + + before(async () => { + redisFacility = new RedisFacility({ ctx: { root: './' } }, {}, {}) + await new Promise(resolve => redisFacility.start(() => resolve())) + redis = redisFacility.cli_rw + }) + + after(async () => { + await new Promise(resolve => redisFacility.stop(() => resolve())) + }) + + beforeEach(async () => { + await redis.flushdb() + redisRateLimiter = RedisRateLimiterUtil.register({ + redis, + command: 'bfxRateLimit', + keyPrefix: 'bfx', + logError: console.error + }) + }) + + it('should check rate limit', async () => { + await redisRateLimiter.checkRateLimit('some-key', 1, 1) + await expect(redisRateLimiter.checkRateLimit('some-key', 1, 1)) + .to.be.rejectedWith('ERR_RATE_LIMIT_EXCEEDED') + }) + + it('should reset rate limit after some time', async () => { + await redisRateLimiter.checkRateLimit('another-key', 1, 1) + await sleep(1100) + await redisRateLimiter.checkRateLimit('another-key', 1, 1) + expect(true).to.deep.eq(true) + }) + + const sleep = (ms) => new Promise((resolve) => { + setTimeout(() => resolve(), ms) + }) +})