Skip to content

Commit 97078c4

Browse files
authored
Feature/rlm common rate limiter (#1090)
* feat: added package node-rate-limiter-flexible * feat: rate limiting response constants * feat: added test cases for common rate limiter * feat: added common rate limiter * refactor: rate limiter import fixed
1 parent dbf6ed6 commit 97078c4

File tree

6 files changed

+1783
-1429
lines changed

6 files changed

+1783
-1429
lines changed

constants/rateLimiting.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const TOO_MANY_REQUESTS = {
2+
ERROR_TYPE: "Too Many Requests",
3+
STATUS_CODE: 429,
4+
};
5+
6+
module.exports = {
7+
TOO_MANY_REQUESTS,
8+
};

middlewares/rateLimiting.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
const { RateLimiterMemory } = require("rate-limiter-flexible");
2+
const { TOO_MANY_REQUESTS } = require("../constants/rateLimiting");
3+
const { getRetrySeconds } = require("../utils/rateLimiting");
4+
5+
// INFO: temporarily added here, will be take from env-var/config
6+
const opts = {
7+
keyPrefix: "commonRateLimiter--login_fail_by_ip_per_minute",
8+
points: 5,
9+
duration: 30,
10+
blockDuration: 60 * 10,
11+
};
12+
const globalRateLimiter = new RateLimiterMemory(opts);
13+
14+
/**
15+
* @param req object represents the HTTP request and has property for the request ip address
16+
* @param res object represents the HTTP response that app sends when it get an HTTP request
17+
* @param next indicates the next middelware function
18+
* @returns Promise, which:
19+
* - `resolved` with next middelware function call `next()`
20+
* - `resolved` with response status set to 429 and message `Too Many Requests` */
21+
async function commonRateLimiter(req, res, next) {
22+
// INFO: get the clientIP when running behind a proxy
23+
const ipAddress = req.headers["x-forwarded-for"] || req.socket.remoteAddress;
24+
let retrySeconds = 0;
25+
try {
26+
const responseGlobalRateLimiter = await globalRateLimiter.get(ipAddress);
27+
if (responseGlobalRateLimiter && responseGlobalRateLimiter.consumedPoints > opts.points) {
28+
retrySeconds = getRetrySeconds(responseGlobalRateLimiter.msBeforeNext);
29+
}
30+
if (retrySeconds > 0) {
31+
throw Error();
32+
}
33+
await globalRateLimiter.consume(ipAddress);
34+
return next();
35+
} catch (error) {
36+
// INFO: sending raw seconds in response,``
37+
// for letting API user decide how to represent this number.
38+
retrySeconds = getRetrySeconds(error?.msBeforeNext, retrySeconds);
39+
res.set({
40+
"Retry-After": retrySeconds,
41+
"X-RateLimit-Limit": opts.points,
42+
"X-RateLimit-Remaining": error?.remainingPoints ?? 0,
43+
"X-RateLimit-Reset": new Date(Date.now() + error?.msBeforeNext),
44+
});
45+
const message = `${TOO_MANY_REQUESTS.ERROR_TYPE}: Retry After ${retrySeconds} seconds, requests limit reached`;
46+
return res.status(TOO_MANY_REQUESTS.STATUS_CODE).json({ message });
47+
}
48+
}
49+
50+
module.exports = {
51+
commonRateLimiter,
52+
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"newrelic": "^9.0.0",
3535
"passport": "^0.6.0",
3636
"passport-github2": "^0.1.12",
37+
"rate-limiter-flexible": "^2.4.1",
3738
"winston": "^3.3.3"
3839
},
3940
"devDependencies": {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
const sinon = require("sinon");
2+
const { TOO_MANY_REQUESTS } = require("../../../constants/rateLimiting");
3+
const { commonRateLimiter } = require("../../../middlewares/rateLimiting");
4+
5+
function mockRequest(ipAddress) {
6+
return {
7+
headers: {
8+
"x-forwarded-for": ipAddress,
9+
},
10+
socket: {
11+
remoteAddress: ipAddress,
12+
},
13+
};
14+
}
15+
16+
function mockResponse(sandbox) {
17+
const res = {};
18+
res.status = sandbox.stub().returns(res);
19+
res.json = sandbox.stub().returns(res);
20+
res.set = sandbox.stub().returns(res);
21+
return res;
22+
}
23+
24+
describe("Rate Limting Middelware", function () {
25+
let req;
26+
let res;
27+
let next;
28+
let sandbox;
29+
30+
beforeEach(function () {
31+
sandbox = sinon.createSandbox();
32+
req = mockRequest("127.0.0.1");
33+
res = mockResponse(sandbox);
34+
next = sandbox.stub();
35+
});
36+
37+
afterEach(function () {
38+
sandbox.restore();
39+
});
40+
41+
it("Should call the next middelware if the request count is under the limit", async function () {
42+
await commonRateLimiter(req, res, next);
43+
sinon.assert.calledOnce(next);
44+
});
45+
46+
it("Should return 429 status code and message `Too many requests` if the request count exceeds the limit", async function () {
47+
const promises = [];
48+
for (let index = 0; index < 10; ++index) {
49+
const promise = commonRateLimiter(req, res, next);
50+
promises.push(promise);
51+
}
52+
await Promise.all(promises);
53+
sinon.assert.calledWithMatch(res.status, TOO_MANY_REQUESTS.STATUS_CODE);
54+
});
55+
56+
it("Should reset the request count after duration has passed", async function () {
57+
const promises = [];
58+
for (let index = 0; index < 10; ++index) {
59+
const promise = commonRateLimiter(req, res, next);
60+
promises.push(promise);
61+
}
62+
await Promise.all(promises);
63+
sinon.assert.calledWithMatch(res.status, TOO_MANY_REQUESTS.STATUS_CODE);
64+
65+
/**
66+
INFO[no-reasoning-only-assumption]:
67+
using setTimeout instead of sinon.FakeTimers,
68+
because clock was ticking for expected duration but
69+
key was not getting deleted from middelware store
70+
*/
71+
setTimeout(async () => {
72+
await commonRateLimiter(req, res, next);
73+
sinon.assert.neverCalledWithMatch(res.status, TOO_MANY_REQUESTS.STATUS_CODE);
74+
sinon.assert.calledOnce(next);
75+
}, 1000);
76+
});
77+
});

utils/rateLimiting.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* @param msBeforeNext
3+
* @param fallbackValue seconds value to fallback on if `msBeforeNext` is falsy
4+
* @returns retrySeconds: number of seconds to wait before making next request
5+
*/
6+
function getRetrySeconds(msBeforeNext, fallbackValue = 1) {
7+
if (!msBeforeNext) return fallbackValue;
8+
return Math.round(msBeforeNext / 1000) || fallbackValue;
9+
}
10+
11+
module.exports = {
12+
getRetrySeconds,
13+
};

0 commit comments

Comments
 (0)