Skip to content
Open
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
2 changes: 2 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
foreground-scripts=true
ignore-scripts=true
14 changes: 14 additions & 0 deletions config/facs/redis.config.json
Original file line number Diff line number Diff line change
@@ -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
}
}
55 changes: 55 additions & 0 deletions libs/redis.rate.limiter.js
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 12 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bitfinex/bfx-facs-redis",
"version": "1.0.0",
"version": "1.0.1",
"private": false,
"description": "Bitfinex Redis Facility",
"author": {
Expand All @@ -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"
Expand All @@ -30,5 +31,14 @@
"name": "prdn",
"email": "[email protected]"
}
]
],
"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"
}
}
52 changes: 52 additions & 0 deletions test/itgr/redis.rate.limiter.itgr.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})