rate-limiter-flexible
Advanced tools
Comparing version 0.1.0 to 0.2.0
const RateLimiterRes = require('./RateLimiterRes'); | ||
const afterConsume = function(resolve, reject, rlKey, results) { | ||
const [resSet, consumed, resTtlMs] = results; | ||
const res = new RateLimiterRes(); | ||
let isFirstInDuration = resSet === 'OK'; | ||
res.remainingPoints = Math.max(this.points - consumed, 0); | ||
if (resTtlMs === -1) { // If rlKey created by incrby() not by set() | ||
isFirstInDuration = true; | ||
res.msBeforeNext = this.duration; | ||
this.redis.expire(rlKey, this.duration); | ||
} else { | ||
res.msBeforeNext = resTtlMs; | ||
} | ||
if (consumed > this.points) { | ||
reject(res); | ||
} else { | ||
if (this.execEvenly && res.msBeforeNext > 0 && !isFirstInDuration) { | ||
const delay = Math.ceil(res.msBeforeNext / (res.remainingPoints + 2)); | ||
setTimeout(resolve, delay, res); | ||
} else { | ||
resolve(res); | ||
} | ||
} | ||
}; | ||
class RateLimiter { | ||
@@ -10,2 +36,3 @@ /** | ||
* duration: 1, // Per seconds | ||
* execEvenly: false, // Execute allowed actions evenly over duration | ||
* } | ||
@@ -17,2 +44,3 @@ */ | ||
this.duration = opts.duration || 1; | ||
this.execEvenly = typeof opts.execEvenly === 'undefined' ? false : Boolean(opts.execEvenly); | ||
} | ||
@@ -27,6 +55,6 @@ | ||
* @param key | ||
* @param points | ||
* @param pointsToConsume | ||
* @returns {Promise<any>} | ||
*/ | ||
consume(key, points = 1) { | ||
consume(key, pointsToConsume = 1) { | ||
return new Promise((resolve, reject) => { | ||
@@ -36,25 +64,9 @@ const rlKey = RateLimiter.getKey(key); | ||
.set(rlKey, 0, 'EX', this.duration, 'NX') | ||
.incrby(rlKey, points) | ||
.incrby(rlKey, pointsToConsume) | ||
.pttl(rlKey) | ||
.exec((err, results) => { | ||
const res = new RateLimiterRes(); | ||
if (err) { | ||
reject(new Error('Redis Client error')); | ||
} else { | ||
const [, consumed, resTtlMs] = results; | ||
res.points = Math.max(this.points - consumed, 0); | ||
if (resTtlMs === -1) { | ||
res.msBeforeNext = this.duration; | ||
this.redis.expire(rlKey, this.duration); | ||
} else { | ||
res.msBeforeNext = resTtlMs; | ||
} | ||
if (consumed > this.points) { | ||
reject(res); | ||
} else { | ||
resolve(res); | ||
} | ||
afterConsume.call(this, resolve, reject, rlKey, results); | ||
} | ||
@@ -67,3 +79,10 @@ }); | ||
const rlKey = RateLimiter.getKey(key); | ||
this.redis.incrby(rlKey, points); | ||
return new Promise((resolve, reject) => { | ||
this.redis.incrby(rlKey, points, (err, value) => { | ||
if (err) { | ||
reject(err); | ||
} | ||
resolve(value); | ||
}); | ||
}); | ||
} | ||
@@ -73,3 +92,10 @@ | ||
const rlKey = RateLimiter.getKey(key); | ||
this.redis.incrby(rlKey, -points); | ||
return new Promise((resolve, reject) => { | ||
this.redis.incrby(rlKey, -points, (err, value) => { | ||
if (err) { | ||
reject(err); | ||
} | ||
resolve(value); | ||
}); | ||
}); | ||
} | ||
@@ -76,0 +102,0 @@ } |
@@ -7,6 +7,8 @@ const expect = require('chai').expect; | ||
describe('RateLimiter with fixed window', () => { | ||
it('consume 1 point', () => { | ||
describe('RateLimiter with fixed window', function() { | ||
this.timeout(5000); | ||
it('consume 1 point', (done) => { | ||
const testKey = 'consume1'; | ||
const rateLimiter = new RateLimiter(redisMockClient, {points: 2, duration: 10}); | ||
const rateLimiter = new RateLimiter(redisMockClient, {points: 2, duration: 5}); | ||
rateLimiter.consume(testKey) | ||
@@ -17,44 +19,100 @@ .then(() => { | ||
expect(consumedPoints).to.equal('1'); | ||
done(); | ||
} | ||
}) | ||
}) | ||
.catch((err) => { | ||
done(err); | ||
}); | ||
}); | ||
it('can not consume more than maximum points', () => { | ||
it('can not consume more than maximum points', (done) => { | ||
const testKey = 'consume2'; | ||
const rateLimiter = new RateLimiter(redisMockClient, {points: 1, duration: 10}); | ||
const rateLimiter = new RateLimiter(redisMockClient, {points: 1, duration: 5}); | ||
rateLimiter.consume(testKey, 2) | ||
.then(() => {}) | ||
.catch((rejRes) => { | ||
expect(rejRes.msBeforeNext > 0).to.equal(true); | ||
expect(rejRes.msBeforeNext >= 0).to.equal(true); | ||
done(); | ||
}) | ||
.catch((err) => { | ||
done(err); | ||
}); | ||
}); | ||
it('makes penalty', () => { | ||
// !!! Uncomment when redis-mock bug fixed | ||
// https://github.com/yeahoffline/redis-mock/pull/67/commits/d1936e5260da8bde252d55e93f01b8f6008de322 | ||
// | ||
// it('consume evenly over duration', (done) => { | ||
// const testKey = 'consumeEvenly'; | ||
// const rateLimiter = new RateLimiter(redisMockClient, {points: 2, duration: 5, execEvenly: true}); | ||
// rateLimiter.consume(testKey) | ||
// .then(() => { | ||
// const timeFirstConsume = Date.now(); | ||
// rateLimiter.consume(testKey) | ||
// .then(() => { | ||
// /* Second consume should be delayed more than 2 seconds | ||
// Explanation: | ||
// 1) consume at 0ms, remaining duration = 4444ms | ||
// 2) delayed consume for (4444 / (0 + 2)) ~= 2222ms, where 2 is a fixed value | ||
// , because it mustn't delay in the beginning and in the end of duration | ||
// 3) consume after 2222ms by timeout | ||
// */ | ||
// expect(Date.now() - timeFirstConsume > 2000).to.equal(true); | ||
// done(); | ||
// }) | ||
// .catch((err) => { | ||
// done(err); | ||
// }); | ||
// }) | ||
// .catch((err) => { | ||
// done(err); | ||
// }); | ||
// }); | ||
it('makes penalty', (done) => { | ||
const testKey = 'penalty1'; | ||
const rateLimiter = new RateLimiter(redisMockClient, {points: 3, duration: 10}); | ||
const rateLimiter = new RateLimiter(redisMockClient, {points: 3, duration: 5}); | ||
rateLimiter.consume(testKey) | ||
.then(() => { | ||
rateLimiter.penalty(testKey); | ||
redisMockClient.get(RateLimiter.getKey(testKey), (err, consumedPoints) => { | ||
if (!err) { | ||
expect(consumedPoints).to.equal('2'); | ||
} | ||
}) | ||
rateLimiter.penalty(testKey) | ||
.then(() => { | ||
redisMockClient.get(RateLimiter.getKey(testKey), (err, consumedPoints) => { | ||
if (!err) { | ||
expect(consumedPoints).to.equal('2'); | ||
done(); | ||
} | ||
}) | ||
}) | ||
.catch((err) => { | ||
done(err); | ||
}); | ||
}) | ||
.catch((err) => { | ||
done(err); | ||
}); | ||
}); | ||
it('reward points', () => { | ||
it('reward points', (done) => { | ||
const testKey = 'penalty2'; | ||
const rateLimiter = new RateLimiter(redisMockClient, {points: 1, duration: 10}); | ||
const rateLimiter = new RateLimiter(redisMockClient, {points: 1, duration: 5}); | ||
rateLimiter.consume(testKey) | ||
.then(() => { | ||
rateLimiter.reward(testKey); | ||
redisMockClient.get(RateLimiter.getKey(testKey), (err, consumedPoints) => { | ||
if (!err) { | ||
expect(consumedPoints).to.equal('0'); | ||
} | ||
}) | ||
rateLimiter.reward(testKey) | ||
.then(() => { | ||
redisMockClient.get(RateLimiter.getKey(testKey), (err, consumedPoints) => { | ||
if (!err) { | ||
expect(consumedPoints).to.equal('0'); | ||
done(); | ||
} | ||
}) | ||
}) | ||
.catch((err) => { | ||
done(err); | ||
}); | ||
}) | ||
.catch((err) => { | ||
done(err); | ||
}); | ||
}); | ||
}); |
module.exports = class RateLimiterRes { | ||
constructor() { | ||
this._msBeforeNext = 0; | ||
this._points = 0; | ||
this._msBeforeNext = 0; // Milliseconds before next action | ||
this._remainingPoints = 0; // Remaining points in current duration | ||
} | ||
@@ -16,10 +16,10 @@ | ||
get points() { | ||
return this._points; | ||
get remainingPoints() { | ||
return this._remainingPoints; | ||
} | ||
set points(p) { | ||
this._points = p; | ||
set remainingPoints(p) { | ||
this._remainingPoints = p; | ||
return this; | ||
} | ||
}; |
@@ -11,3 +11,3 @@ const expect = require('chai').expect; | ||
it('setup defaults on construct', () => { | ||
expect(rateLimiterRes.msBeforeNext === 0 && rateLimiterRes.points === 0).to.be.true; | ||
expect(rateLimiterRes.msBeforeNext === 0 && rateLimiterRes.remainingPoints === 0).to.be.true; | ||
}); | ||
@@ -21,5 +21,5 @@ | ||
it('points set and get', () => { | ||
rateLimiterRes.points = 4; | ||
expect(rateLimiterRes.points).to.equal(4); | ||
rateLimiterRes.remainingPoints = 4; | ||
expect(rateLimiterRes.remainingPoints).to.equal(4); | ||
}); | ||
}); |
{ | ||
"name": "rate-limiter-flexible", | ||
"version": "0.1.0", | ||
"version": "0.2.0", | ||
"description": "Flexible API rate limiter backed by Redis for distributed node.js applications", | ||
@@ -8,2 +8,3 @@ "main": "index.js", | ||
"test": "./node_modules/istanbul/lib/cli.js cover ./node_modules/.bin/_mocha lib/**/**.test.js", | ||
"debug-test": "mocha --inspect-brk lib/**/**.test.js", | ||
"coveralls": "cat ./coverage/lcov.info | node node_modules/.bin/coveralls" | ||
@@ -10,0 +11,0 @@ }, |
@@ -58,2 +58,12 @@ [![Build Status](https://travis-ci.org/animir/node-rate-limiter-flexible.png)](https://travis-ci.org/animir/node-rate-limiter-flexible) | ||
## Options | ||
* `points` Maximum number of points can be consumed over duration | ||
* `duration` Number of seconds before points are reset | ||
* `execEvenly` Delay action to be executed evenly over duration | ||
First action in duration is executed without delay. | ||
All next allowed actions in current duration are delayed by formula `msBeforeDurationEnd / (remainingPoints + 2)` | ||
It allows to cut off load peaks. | ||
Note: it isn't recommended to use it for long duration, as it may delay action for too long | ||
## API | ||
@@ -68,3 +78,3 @@ | ||
msBeforeNext: 250, // Number of milliseconds before next action can be done | ||
points: 0 // Number of left points in current duration | ||
remainingPoints: 0 // Number of remaining points in current duration | ||
} | ||
@@ -88,2 +98,4 @@ ```` | ||
Note: Depending on time penalty may go to next durations | ||
Doesn't return anything | ||
@@ -95,2 +107,4 @@ | ||
Note: Depending on time reward may go to next durations | ||
Doesn't return anything |
43599
13
237
107