Skip to content

Commit fdbe863

Browse files
feat: helper for rate limits
1 parent e5b8eda commit fdbe863

File tree

5 files changed

+135
-2
lines changed

5 files changed

+135
-2
lines changed

.npmrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
foreground-scripts=true
2+
ignore-scripts=true

config/facs/redis.config.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"sk0": {
3+
"host": "127.0.0.1",
4+
"port": 6379
5+
},
6+
"uc0": {
7+
"host": "127.0.0.1",
8+
"port": 6379
9+
},
10+
"test": {
11+
"host": "127.0.0.1",
12+
"port": 6380
13+
}
14+
}

libs/redis.rate.limiter.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
'use strict'
2+
3+
const crypto = require('crypto')
4+
const { GrcUserError, GrcGenericError } = require('@bitfinex/lib-util-err-js')
5+
6+
const RATE_LIMIT_LUA = `
7+
local attempts = redis.call('INCR', KEYS[1])
8+
if attempts == 1 then
9+
redis.call('EXPIRE', KEYS[1], ARGV[1])
10+
end
11+
return attempts
12+
`
13+
14+
class RedisRateLimiterUtil {
15+
static register ({ redis, command, keyPrefix, logError }) {
16+
return new RedisRateLimiterUtil({ redis, command, keyPrefix, logError })._register()
17+
}
18+
19+
constructor ({ redis, command, keyPrefix, logError }) {
20+
this._redis = redis
21+
this._command = command
22+
this._keyPrefix = keyPrefix
23+
this._logError = logError
24+
this._registered = false
25+
}
26+
27+
async checkRateLimit (key, expiry, maxAttempts) {
28+
if (!this._registered) return this._logError(new GrcGenericError('RedisRateLimiter not registered'))
29+
const attempts = await this._redis[this._command](`${this._keyPrefix}:${key}`, expiry)
30+
if (attempts > maxAttempts) {
31+
throw new GrcUserError('ERR_RATE_LIMIT_EXCEEDED')
32+
}
33+
}
34+
35+
_register () {
36+
const md5 = crypto.createHash('md5').update(RATE_LIMIT_LUA, 'utf8').digest('hex')
37+
const commandSymbol = Symbol.for(this._command)
38+
if (this._redis[this._command] || this._redis[commandSymbol] === md5) return this
39+
try {
40+
this._redis.defineCommand(this._command, {
41+
numberOfKeys: 1,
42+
lua: RATE_LIMIT_LUA
43+
})
44+
this._redis[commandSymbol] = md5
45+
this._registered = true
46+
} catch (error) {
47+
this._logError(error)
48+
}
49+
return this
50+
}
51+
}
52+
53+
module.exports = {
54+
RedisRateLimiterUtil
55+
}

package.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bitfinex/bfx-facs-redis",
3-
"version": "1.0.0",
3+
"version": "1.0.1",
44
"private": false,
55
"description": "Bitfinex Redis Facility",
66
"author": {
@@ -13,6 +13,7 @@
1313
],
1414
"dependencies": {
1515
"@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git",
16+
"@bitfinex/lib-util-err-js": "git+https://github.com/bitfinexcom/lib-util-err-js.git",
1617
"async": "^3.2.1",
1718
"ioredis": "^5.3.0",
1819
"lodash": "^4.17.21"
@@ -30,5 +31,14 @@
3031
"name": "prdn",
3132
"email": "paolo@bitfinex.com"
3233
}
33-
]
34+
],
35+
"devDependencies": {
36+
"chai": "4.5.0",
37+
"chai-as-promised": "7.1.2",
38+
"mocha": "10.8.2",
39+
"standard": "17.1.2"
40+
},
41+
"scripts": {
42+
"test": "NODE_ENV=test mocha -R spec -b --recursive --timeout 10000 test"
43+
}
3444
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/* eslint-env mocha */
2+
3+
'use strict'
4+
5+
const chai = require('chai')
6+
const chaiAsPromised = require('chai-as-promised')
7+
chai.use(chaiAsPromised)
8+
9+
const { expect } = require('chai')
10+
const RedisFacility = require('../../index')
11+
const { RedisRateLimiterUtil } = require('../../libs/redis.rate.limiter')
12+
13+
describe('Redis Rate Limiter Integration', () => {
14+
let redisFacility, redis, redisRateLimiter
15+
16+
before(async () => {
17+
redisFacility = new RedisFacility({ ctx: { root: './' } }, {}, {})
18+
await new Promise(resolve => redisFacility.start(() => resolve()))
19+
redis = redisFacility.cli_rw
20+
})
21+
22+
after(async () => {
23+
await new Promise(resolve => redisFacility.stop(() => resolve()))
24+
})
25+
26+
beforeEach(async () => {
27+
await redis.flushdb()
28+
redisRateLimiter = RedisRateLimiterUtil.register({
29+
redis,
30+
command: 'bfxRateLimit',
31+
keyPrefix: 'bfx',
32+
logError: console.error
33+
})
34+
})
35+
36+
it('should check rate limit', async () => {
37+
await redisRateLimiter.checkRateLimit('some-key', 1, 1)
38+
await expect(redisRateLimiter.checkRateLimit('some-key', 1, 1))
39+
.to.be.rejectedWith('ERR_RATE_LIMIT_EXCEEDED')
40+
})
41+
42+
it('should reset rate limit after some time', async () => {
43+
await redisRateLimiter.checkRateLimit('another-key', 1, 1)
44+
await sleep(1100)
45+
await redisRateLimiter.checkRateLimit('another-key', 1, 1)
46+
expect(true).to.deep.eq(true)
47+
})
48+
49+
const sleep = (ms) => new Promise((resolve) => {
50+
setTimeout(() => resolve(), ms)
51+
})
52+
})

0 commit comments

Comments
 (0)