sliding-window-limiter 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # sliding-window-limiter
2
+
3
+ A JavaScript implementation of the Sliding Window rate limiting algorithm.
4
+
5
+ ## Features
6
+
7
+ - Sliding window rate limiting with configurable window size, unit, and slice width
8
+ - Pluggable persistence store (in-memory by default)
9
+ - Written in modern JavaScript, using [lodash](https://lodash.com/) and [luxon](https://moment.github.io/luxon/)
10
+ - Thoroughly tested with Jest
11
+
12
+ ## Installation
13
+
14
+ ```sh
15
+ npm install sliding-window-limiter
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```js
21
+ const { RateLimiter } = require('sliding-window-limiter');
22
+
23
+ const limiter = new RateLimiter({
24
+ name: 'api-rate-limit',
25
+ max: 100, // max requests per window
26
+ window: {
27
+ unit: 'minute', // time unit for window
28
+ size: 10, // number of slices in window
29
+ width: 1, // width of each slice
30
+ },
31
+ store: customStore, // optional: provide your own store
32
+ });
33
+
34
+ // To check and update the rate limit:
35
+ const allowed = await limiter.update(1, new Date());
36
+ if (!allowed) {
37
+ // Rate limit exceeded
38
+ }
39
+ ```
40
+
41
+ ## API
42
+
43
+ ### `RateLimiter`
44
+
45
+ #### Constructor
46
+
47
+ ```js
48
+ new RateLimiter(config)
49
+ ```
50
+
51
+ - `config.name` (string): Unique name for the limiter
52
+ - `config.max` (number): Maximum allowed value in the window
53
+ - `config.window` (object): Window configuration
54
+ - `unit` (string): Time unit (`second`, `minute`, `hour`, etc.)
55
+ - `size` (number): Number of slices in the window
56
+ - `width` (number): Width of each slice
57
+ - `config.store` (object): Store for persistence (must implement `get` and `set`)
58
+
59
+ #### Methods
60
+
61
+ - `static async RateLimiter.load(config)`: Loads a limiter and its state from the store
62
+ - `async update(value, timestamp)`: Attempts to add `value` at `timestamp`. Returns `true` if allowed, `false` if limit exceeded.
63
+ - `set(state)`: Sets the internal window state
64
+ - `valueOf()`: Returns the current sum of the window
65
+
66
+ ### `Window`
67
+
68
+ - Internal class for managing the sliding window logic
69
+
70
+ ## Testing
71
+
72
+ Run all tests with:
73
+
74
+ ```sh
75
+ npm test
76
+ ```
77
+
78
+ ## License
79
+
80
+ ISC
81
+
82
+ ## Author
83
+
84
+ Dima Michaelov
85
+ ```
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ const {RateLimiter} = require("./lib/rate_limiter");
2
+
3
+ module.exports = {
4
+ RateLimiter,
5
+ };
@@ -0,0 +1,9 @@
1
+ class Store {
2
+ get(key) { return null; }
3
+ set(key, value) { }
4
+ };
5
+
6
+ module.exports = {
7
+ Store,
8
+ NullStore: new Store(),
9
+ };
@@ -0,0 +1,78 @@
1
+ const {isNumber} = require('lodash/fp');
2
+ const {Window} = require('./window');
3
+ const {NullStore} = require('./null_store');
4
+
5
+ // window with twenty three-second buckets
6
+ const DefaultConfig = () => ({
7
+ name: 'request-rate-per-minute-max10',
8
+ max: 10,
9
+ window: {
10
+ unit: 'second', // unit of time ('second', 'minute', 'hour', 'day', 'week', 'month', 'year')
11
+ size: 20, // number of time slices
12
+ width: 3, // width of slice
13
+ },
14
+ store: NullStore, // store to handle persistence
15
+ });
16
+
17
+ class RateLimiter {
18
+ constructor(config = DefaultConfig()) {
19
+ ({name: this.name, max: this.max, store: this.store, window: this.windowConfig} = config);
20
+ if (!this.name)
21
+ throw new TypeError('name must be specified and be unique');
22
+ if (!isNumber(this.max) || this.max <= 0)
23
+ throw new TypeError('max must be a positive number');
24
+ if (!this.store)
25
+ throw new TypeError('store must be specified');
26
+ if (!this.windowConfig)
27
+ throw new TypeError('window configuration must be specified');
28
+ }
29
+
30
+ static async load(config) {
31
+ if (!config.name)
32
+ throw new TypeError('name must be specified and be unique');
33
+ if (!config.store)
34
+ throw new TypeError('store must be specified');
35
+
36
+ try {
37
+ const state = await config.store.get(config.name);
38
+ return new RateLimiter(config).set(state);
39
+ }
40
+ catch (err) {
41
+ throw new Error(`Failed to load rate-limit window ${config.name}`, {cause: err});
42
+ }
43
+ }
44
+
45
+ // should update the buckets according to configuration
46
+ // returns true if update successful
47
+ async update(value, timestamp) {
48
+ if (!isNumber(value) || value <= 0)
49
+ throw new TypeError('value must be a positive number');
50
+
51
+ try {
52
+ this.window = this.window || Window.fromConfig(this.windowConfig);
53
+ if (this.max < this.valueOf() + value)
54
+ return false;
55
+
56
+ this.window.update(value, timestamp);
57
+ await this.store.set(this.name, this.window.toObject());
58
+ return true;
59
+ }
60
+ catch (err) {
61
+ throw new Error(`Failed to update rate-limit window ${this.name}`, {cause: err});
62
+ }
63
+ }
64
+
65
+ set(state) {
66
+ if (this.window) return;
67
+ this.window = state ? new Window(state) : Window.fromConfig(this.windowConfig);
68
+ return this;
69
+ }
70
+
71
+ valueOf() {
72
+ return this.window?.valueOf();
73
+ }
74
+ };
75
+
76
+ module.exports = {
77
+ RateLimiter,
78
+ };
package/lib/window.js ADDED
@@ -0,0 +1,101 @@
1
+ const {isNumber, isString, min, rangeStep, slice, sum} = require('lodash/fp');
2
+ const {DateTime} = require('luxon');
3
+
4
+ // window with twenty three-second timeSlices
5
+ const DefaultWindow = () => ({
6
+ timeSlices: [],
7
+ updated: DateTime.now(),
8
+ unit: 'second',
9
+ width: 3,
10
+ });
11
+
12
+ const zeroes = rangeStep(0, 0);
13
+ const mod = (a, b) => ((a % b) + b) % b;
14
+ const validUnit = unit => ['second', 'minute', 'hour', 'day', 'week', 'month', 'year'].includes(unit);
15
+
16
+ class Window {
17
+ constructor(data = DefaultWindow()) {
18
+ ({timeSlices: this.timeSlices, updated: this.updated, unit: this.unit, width: this.width} = data);
19
+ if (!this.unit || !validUnit(this.unit))
20
+ throw new TypeError("unit must be one of 'second', 'minute', 'hour', 'day', 'week', 'month', 'year'")
21
+ if (isString(this.updated))
22
+ this.updated = DateTime.fromISO(this.updated);
23
+ if (!this.updated.isValid) throw new TypeError('updated must be luxon DateTime');
24
+ }
25
+
26
+ static fromConfig(config) {
27
+ if (!isNumber(config.size) || config.size <= 0)
28
+ throw new TypeError('config.size must be a positive number');
29
+ return new Window({
30
+ timeSlices: zeroes(config.size),
31
+ updated: DateTime.now(),
32
+ unit: config.unit,
33
+ width: config.width,
34
+ });
35
+ }
36
+
37
+ update(value, timestamp = DateTime.now()) {
38
+ if (!isNumber(value))
39
+ throw new TypeError('value must be a number');
40
+ if (value < 0)
41
+ throw new TypeError('value must be a positive number');
42
+
43
+ let _timestamp = timestamp;
44
+ if (timestamp instanceof Date)
45
+ _timestamp = DateTime.fromJSDate(timestamp);
46
+ if (isString(timestamp))
47
+ _timestamp = DateTime.fromISO(timestamp);
48
+ if (!_timestamp.isValid)
49
+ throw new TypeError('timestamp must be valid DateTime, Date or ISO string');
50
+
51
+ this.recalculate(_timestamp);
52
+ this.timeSlices[0] += value;
53
+ this.updated = _timestamp;
54
+ }
55
+
56
+ slicesBetween(now, start) {
57
+ let count = now.diff(start, this.unit).get(this.unit);
58
+ if (this.width) {
59
+ count = count < this.width ? 0 : Math.floor(count / this.width);
60
+ }
61
+ return count;
62
+ }
63
+
64
+ start() {
65
+ let time = this.updated;
66
+ if (this.width) {
67
+ const val = time.get(this.unit);
68
+ time = time.set({[this.unit]: val - (mod(val, this.width))});
69
+ }
70
+ return time.startOf(this.unit);
71
+ }
72
+
73
+ recalculate(timestamp) {
74
+ const slicesCount = this.slicesBetween(timestamp, this.start());
75
+ if (slicesCount > 0) {
76
+ const invalidSlices = min([slicesCount, this.timeSlices.length]);
77
+ this.timeSlices = [
78
+ ...zeroes(invalidSlices),
79
+ ...slice(0, -invalidSlices)(this.timeSlices),
80
+ ];
81
+ }
82
+ }
83
+
84
+ valueOf() {
85
+ return sum(this.timeSlices);
86
+ }
87
+
88
+ toObject() {
89
+ return {
90
+ timeSlices: this.timeSlices,
91
+ updated: this.updated.toISO(),
92
+ unit: this.unit,
93
+ width: this.width,
94
+ };
95
+ }
96
+ }
97
+
98
+ module.exports = {
99
+ DefaultWindow,
100
+ Window,
101
+ };
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "sliding-window-limiter",
3
+ "version": "1.0.0",
4
+ "description": "Implementation of the Sliding Window rate limiting algorithm",
5
+ "keywords": [
6
+ "sliding-window",
7
+ "rolling-buckets",
8
+ "rate-limiting"
9
+ ],
10
+ "homepage": "https://github.com/Dimch/sliding-window-limiter#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/Dimch/sliding-window-limiter/issues"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/Dimch/sliding-window-limiter.git"
17
+ },
18
+ "license": "ISC",
19
+ "author": "Dima Michaelov",
20
+ "type": "commonjs",
21
+ "main": "index.js",
22
+ "scripts": {
23
+ "test": "jest"
24
+ },
25
+ "dependencies": {
26
+ "lodash": "^4.17.21"
27
+ },
28
+ "devDependencies": {
29
+ "jest": "^30.2.0",
30
+ "luxon": "^3.7.2"
31
+ }
32
+ }
@@ -0,0 +1,151 @@
1
+ const {DateTime} = require('luxon');
2
+ const {cloneDeep} = require('lodash/fp');
3
+ const {RateLimiter} = require('../lib/rate_limiter');
4
+ const {Window} = require('../lib/window');
5
+ const {NullStore} = require('../lib/null_store');
6
+
7
+ const MockStoreFactory = () => {
8
+ let internalValue = {
9
+ timeSlices: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
10
+ updated: '2025-01-01T00:00:00',
11
+ unit: 'second',
12
+ width: 3,
13
+ }
14
+ return ({
15
+ get: jest.fn(name => name !== 'ip-req-per-minute-max10-u00001' ? null : cloneDeep(internalValue)),
16
+ set: jest.fn((name, value) => (internalValue = cloneDeep(value))),
17
+ });
18
+ };
19
+
20
+ const MockStore = MockStoreFactory();
21
+
22
+ const ErrorStore = ({
23
+ get: jest.fn().mockRejectedValue(new Error('network error')),
24
+ set: jest.fn().mockRejectedValue(new Error('network error')),
25
+ });
26
+
27
+ const DefaultOptions = () => ({
28
+ name: 'ip-req-per-minute-max10-u00001',
29
+ max: 10,
30
+ window: {
31
+ unit: 'second',
32
+ size: 20,
33
+ width: 3,
34
+ },
35
+ store: MockStore,
36
+ });
37
+
38
+ describe('RateLimiter', () => {
39
+
40
+ describe('#constructor', () => {
41
+ test('should correctly initialize RateLimiter', () => {
42
+ const lim = new RateLimiter(DefaultOptions());
43
+ expect(lim.windowConfig).toStrictEqual({unit: 'second', size: 20, width: 3});
44
+ expect(lim.max).toBe(10);
45
+ expect(lim.name).toBe('ip-req-per-minute-max10-u00001');
46
+ expect(lim.store).toBe(MockStore);
47
+ expect(lim.window).toBeFalsy();
48
+ });
49
+
50
+ test('should throw error if one of properties is invalid', () => {
51
+ let fn = () => new RateLimiter({});
52
+ expect(fn).toThrow('name must be specified and be unique');
53
+ fn = () => new RateLimiter({name: 'ip-hour-0001', max: 0});
54
+ expect(fn).toThrow('max must be a positive number');
55
+ fn = () => new RateLimiter({name: 'ip-hour-0001', max: 20});
56
+ expect(fn).toThrow('store must be specified');
57
+ fn = () => new RateLimiter({name: 'ip-hour-0001', max: 20, store: NullStore});
58
+ expect(fn).toThrow('window configuration must be specified');
59
+ });
60
+ });
61
+
62
+ describe('#load', () => {
63
+ test('should correctly initialize RateLimiter and load Window state', async () => {
64
+ const lim = await RateLimiter.load(DefaultOptions());
65
+ expect(lim.name).toBe('ip-req-per-minute-max10-u00001');
66
+ expect(lim.max).toBe(10);
67
+ expect(lim.store).toBe(MockStore);
68
+ expect(MockStore.get).toHaveBeenCalledTimes(1);
69
+ expect(MockStore.get.mock.calls[0][0]).toBe(DefaultOptions().name);
70
+ expect(lim.window).toStrictEqual(new Window({
71
+ timeSlices: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
72
+ updated: '2025-01-01T00:00:00.000+02:00',
73
+ unit: 'second',
74
+ width: 3,
75
+ }));
76
+ });
77
+
78
+ test('should throw error if one of properties is invalid', async () => {
79
+ let fn = () => RateLimiter.load({});
80
+ await expect(fn()).rejects.toThrow('name must be specified and be unique');
81
+ fn = () => RateLimiter.load({name: 'ip-hour-0001'});
82
+ await expect(fn()).rejects.toThrow('store must be specified');
83
+ });
84
+
85
+ test('should throw an error if failed to load state', async () => {
86
+ const fn = () => RateLimiter.load({...DefaultOptions(), store: ErrorStore});
87
+ await expect(fn()).rejects.toThrow('Failed to load rate-limit window ip-req-per-minute-max10-u00001');
88
+ });
89
+ });
90
+
91
+ describe('#set', () => {
92
+ test('should not reset existing window state', async () => {
93
+ const lim = await RateLimiter.load(DefaultOptions());
94
+ expect(lim.valueOf()).toBe(0);
95
+ lim.set({
96
+ timeSlices: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
97
+ updated: '2025-01-01T00:00:00',
98
+ unit: 'second',
99
+ width: 3,
100
+ });
101
+ expect(lim.valueOf()).toBe(0);
102
+ });
103
+
104
+ test('should set provided set', () => {
105
+ const lim = new RateLimiter(DefaultOptions());
106
+ expect(lim.valueOf()).toBe(undefined);
107
+ lim.set({
108
+ timeSlices: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
109
+ updated: '2025-01-01T00:00:00',
110
+ unit: 'second',
111
+ width: 3,
112
+ });
113
+ expect(lim.valueOf()).toBe(1);
114
+ });
115
+
116
+ test('should set default set if state is empty', async () => {
117
+ const lim = await RateLimiter.load({...DefaultOptions(), store: NullStore});
118
+ expect(lim.valueOf()).toBe(0);
119
+ expect(lim.window.timeSlices).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
120
+ expect(lim.window.unit).toBe(lim.windowConfig.unit);
121
+ expect(lim.window.width).toBe(lim.windowConfig.width);
122
+ });
123
+ });
124
+
125
+ describe('#update', () => {
126
+ test('should should throw error if one of arguments is invalid', async () => {
127
+ const lim = new RateLimiter(DefaultOptions());
128
+ const fn = () => lim.update(0);
129
+ await expect(fn()).rejects.toThrow('value must be a positive number');
130
+ });
131
+
132
+ test('should return false if getting over max threshold', async () => {
133
+ const lim = new RateLimiter(DefaultOptions());
134
+ const fn = () => lim.update(40, '2025-01-01T00:00:30Z');
135
+ await expect(fn()).resolves.toBe(false);
136
+ });
137
+
138
+ test('should return true if window state is updated', async () => {
139
+ const lim = new RateLimiter(DefaultOptions());
140
+ const fn = () => lim.update(10, '2025-01-01T00:00:30Z');
141
+ await expect(fn()).resolves.toBe(true);
142
+ expect(lim.valueOf()).toBe(10);
143
+ });
144
+
145
+ test('should throw an error if failed to update store', async () => {
146
+ const lim = new RateLimiter({...DefaultOptions(), store: ErrorStore});
147
+ const fn = () => lim.update(1, '2025-01-01T00:00:00Z');
148
+ await expect(fn()).rejects.toThrow('Failed to update rate-limit window ip-req-per-minute-max10-u00001');
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,79 @@
1
+ const {DateTime} = require('luxon');
2
+ const {isString} = require('lodash/fp');
3
+ const {Window} = require('../lib/window');
4
+
5
+ describe('Window', () => {
6
+
7
+ describe('#constructor', () => {
8
+ test('should initialize Window', () => {
9
+ const win = new Window({timeSlices: [0, 0, 0, 0, 0], unit: 'minute', width: 2, updated: '2025-01-01T00:00:00Z'});
10
+ expect(win.timeSlices).toHaveLength(5);
11
+ expect(win.timeSlices).toStrictEqual([0, 0, 0, 0, 0]);
12
+ expect(win.unit).toBe('minute');
13
+ expect(win.width).toBe(2);
14
+ expect(win.updated.toUTC().toISO()).toBe('2025-01-01T00:00:00.000Z');
15
+ });
16
+
17
+ test('should throw error if updated is invalid', () => {
18
+ const fn = () => new Window({timeSlices: [0, 0, 0, 0, 0], unit: 'minute', width: 2, updated: 'abc'});
19
+ expect(fn).toThrow(TypeError);
20
+ });
21
+ });
22
+
23
+ describe('#fromConfig', () => {
24
+ test('should initialize Window', () => {
25
+ const win = Window.fromConfig({unit: 'minute', width: 2, size: 5});
26
+ expect(win.timeSlices).toHaveLength(5);
27
+ expect(win.timeSlices).toStrictEqual([0, 0, 0, 0, 0]);
28
+ expect(win.unit).toBe('minute');
29
+ expect(win.width).toBe(2);
30
+ expect(DateTime.isDateTime(win.updated)).toBe(true);
31
+ });
32
+ });
33
+
34
+ describe('#slicesBetween', () => {
35
+ const win = Window.fromConfig({unit: 'minute', width: 2, size: 5});
36
+ test('should return number of slices between timestamps', () => {
37
+ const date0 = '2014-01-01T00:00:00Z';
38
+ const date1 = '2014-01-01T00:01:00Z';
39
+ const date2 = '2014-01-01T00:05:00Z';
40
+ expect(win.slicesBetween(DateTime.fromISO(date0), DateTime.fromISO(date0))).toBe(0);
41
+ expect(win.slicesBetween(DateTime.fromISO(date1), DateTime.fromISO(date0))).toBe(0);
42
+ expect(win.slicesBetween(DateTime.fromISO(date2), DateTime.fromISO(date0))).toBe(2);
43
+ });
44
+ });
45
+
46
+ describe('#valueOf', () => {
47
+ const win = new Window({timeSlices: [0, 0, 0, 0, 0], unit: 'minute', width: 2, updated: '2025-01-01T00:00:00Z'});
48
+ test('should return sum of values', () => {
49
+ expect(win.valueOf()).toBe(0);
50
+ win.update(1, '2025-01-01T00:01:00Z');
51
+ expect(win.valueOf()).toBe(1);
52
+ });
53
+ });
54
+
55
+ describe('#toObject', () => {
56
+ const win = new Window({timeSlices: [0, 0, 0, 0, 0], unit: 'minute', width: 2, updated: '2025-01-01T00:00:00Z'});
57
+ test('should return sum of values', () => {
58
+ const obj = win.toObject();
59
+ expect(obj.timeSlices).toEqual([0, 0, 0, 0, 0]);
60
+ expect(obj.unit).toEqual('minute');
61
+ expect(obj.width).toEqual(2);
62
+ expect(isString(obj.updated)).toBe(true);
63
+ win.update(1, '2025-01-01T00:01:00Z');
64
+ expect(win.toObject().timeSlices).toEqual([1, 0, 0, 0, 0]);
65
+ });
66
+ });
67
+
68
+ describe('#update()', () => {
69
+ test('should throw error if invalid parameter', () => {
70
+ const win = new Window({timeSlices: [0, 0, 0, 0, 0], unit: 'minute', width: 2, updated: '2025-01-01T00:00:00Z'});
71
+ let fn = () => win.update('a');
72
+ expect(fn).toThrow('value must be a number');
73
+ fn = () => win.update(-1);
74
+ expect(fn).toThrow('value must be a positive number');
75
+ fn = () => win.update(1, '2025-01-02 00:00:00');
76
+ expect(fn).toThrow('timestamp must be valid DateTime, Date or ISO string');
77
+ });
78
+ });
79
+ });