sliding-window-counter-rate-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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shubham Kakade
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,339 @@
1
+ # Sliding Window Counter Rate Limiter
2
+
3
+ A production-ready **Sliding Window Counter rate limiter** for Express applications using **Redis and Lua**.
4
+
5
+ It provides atomic rate limiting, configurable client identification, rate-limit headers, Redis failure strategies, and automatic Redis reconnection support.
6
+
7
+ ## Features
8
+
9
+ - Sliding Window Counter algorithm
10
+ - Redis-backed distributed rate limiting
11
+ - Atomic Redis + Lua execution
12
+ - Express middleware
13
+ - Custom client/key identification
14
+ - Configurable rate limits
15
+ - Configurable Redis key prefix
16
+ - `fail-open` and `fail-closed` Redis strategies
17
+ - Rate-limit response headers
18
+ - `Retry-After` header for blocked requests
19
+ - Input configuration validation
20
+ - Concurrent request safety
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ npm install sliding-window-counter-rate-limiter redis
26
+ ```
27
+
28
+ ## Requirements
29
+
30
+ - Node.js 18+
31
+ - Redis 6+
32
+ - Express 4+ or 5+
33
+
34
+ ## Basic Usage
35
+
36
+ ```js
37
+ const express = require("express");
38
+ const { createClient } = require("redis");
39
+
40
+ const { rateLimiter } = require("sliding-window-counter-rate-limiter");
41
+
42
+ const app = express();
43
+
44
+ const redis = createClient({
45
+ url: "redis://localhost:6379",
46
+ });
47
+
48
+ redis.on("error", (error) => {
49
+ console.error("Redis error:", error);
50
+ });
51
+
52
+ redis.connect();
53
+
54
+ const limiter = rateLimiter({
55
+ redis,
56
+ limit: 100,
57
+ windowMs: 60_000,
58
+ });
59
+
60
+ app.use(limiter);
61
+
62
+ app.get("/", (req, res) => {
63
+ res.json({
64
+ success: true,
65
+ message: "Request allowed",
66
+ });
67
+ });
68
+
69
+ app.listen(3000, () => {
70
+ console.log("Server running on port 3000");
71
+ });
72
+ ```
73
+
74
+ The above configuration allows:
75
+
76
+ ```text
77
+ 100 requests
78
+ per 60 seconds
79
+ per client IP
80
+ ```
81
+
82
+ The default client identifier is:
83
+
84
+ ```js
85
+ (req) => req.ip;
86
+ ```
87
+
88
+ ## Custom Client Identification
89
+
90
+ You can define how clients should be identified using `keyGenerator`.
91
+
92
+ ### User ID
93
+
94
+ ```js
95
+ const limiter = rateLimiter({
96
+ redis,
97
+ limit: 100,
98
+ windowMs: 60_000,
99
+
100
+ keyGenerator: (req) => req.user.id,
101
+ });
102
+ ```
103
+
104
+ ### API Key
105
+
106
+ ```js
107
+ const limiter = rateLimiter({
108
+ redis,
109
+ limit: 1000,
110
+ windowMs: 60_000,
111
+
112
+ keyGenerator: (req) => req.headers["x-api-key"],
113
+ });
114
+ ```
115
+
116
+ ### IP Address
117
+
118
+ This is the default:
119
+
120
+ ```js
121
+ const limiter = rateLimiter({
122
+ redis,
123
+ limit: 100,
124
+ windowMs: 60_000,
125
+
126
+ keyGenerator: (req) => req.ip,
127
+ });
128
+ ```
129
+
130
+ If your application is behind a reverse proxy, configure Express `trust proxy` appropriately so `req.ip` represents the intended client.
131
+
132
+ ## Configuration
133
+
134
+ | Option | Type | Default | Description |
135
+ | -------------------- | ------------ | --------------- | --------------------------------- |
136
+ | `redis` | Redis client | Required | Redis client instance |
137
+ | `limit` | number | Required | Maximum requests allowed |
138
+ | `windowMs` | number | Required | Rate-limit window in milliseconds |
139
+ | `keyGenerator` | function | `req => req.ip` | Generates the client identifier |
140
+ | `keyPrefix` | string | `"rate-limit"` | Redis key namespace |
141
+ | `redisErrorStrategy` | string | `"fail-open"` | Redis failure behavior |
142
+
143
+ ### Example
144
+
145
+ ```js
146
+ const limiter = rateLimiter({
147
+ redis,
148
+ limit: 100,
149
+ windowMs: 60_000,
150
+ keyGenerator: (req) => req.user.id,
151
+ keyPrefix: "my-api",
152
+ redisErrorStrategy: "fail-closed",
153
+ });
154
+ ```
155
+
156
+ ## Redis Error Strategies
157
+
158
+ ### `fail-open`
159
+
160
+ ```js
161
+ redisErrorStrategy: "fail-open";
162
+ ```
163
+
164
+ If Redis becomes unavailable, the request continues to the application:
165
+
166
+ ```text
167
+ Request
168
+
169
+ Rate limiter
170
+
171
+ Redis unavailable
172
+
173
+ next()
174
+
175
+ Application
176
+ ```
177
+
178
+ This prioritizes application availability over strict rate limiting.
179
+
180
+ ### `fail-closed`
181
+
182
+ ```js
183
+ redisErrorStrategy: "fail-closed";
184
+ ```
185
+
186
+ If Redis becomes unavailable, the middleware returns:
187
+
188
+ ```http
189
+ 503 Service Unavailable
190
+ ```
191
+
192
+ ```json
193
+ {
194
+ "success": false,
195
+ "message": "Rate limiter temporarily unavailable"
196
+ }
197
+ ```
198
+
199
+ This prioritizes strict rate-limit enforcement.
200
+
201
+ ## Rate-Limit Headers
202
+
203
+ Successful requests include:
204
+
205
+ ```http
206
+ RateLimit-Limit: 100
207
+ RateLimit-Remaining: 99
208
+ RateLimit-Reset: 42
209
+ ```
210
+
211
+ When the rate limit is exceeded:
212
+
213
+ ```http
214
+ HTTP/1.1 429 Too Many Requests
215
+ Retry-After: 42
216
+ ```
217
+
218
+ Response:
219
+
220
+ ```json
221
+ {
222
+ "success": false,
223
+ "message": "Too many requests"
224
+ }
225
+ ```
226
+
227
+ ## How Sliding Window Counter Works
228
+
229
+ The algorithm divides time into fixed windows.
230
+
231
+ For example, with a 60-second window:
232
+
233
+ ```text
234
+ Previous Window Current Window
235
+ ─────────────────── ───────────────────
236
+ 60 seconds 60 seconds
237
+ ```
238
+
239
+ Instead of completely ignoring the previous window, the algorithm gives it a decreasing weight.
240
+
241
+ The estimated request count is:
242
+
243
+ ```text
244
+ estimatedCount =
245
+ previousCount × (1 - progress)
246
+ + currentCount
247
+ ```
248
+
249
+ Where:
250
+
251
+ ```text
252
+ progress =
253
+ elapsedTime / windowSize
254
+ ```
255
+
256
+ Example:
257
+
258
+ ```text
259
+ Limit = 10
260
+
261
+ Previous window = 6 requests
262
+ Current window = 2 requests
263
+
264
+ 50% of current window has elapsed
265
+
266
+ Previous contribution:
267
+ 6 × (1 - 0.5)
268
+ = 3
269
+
270
+ Estimated count:
271
+ 3 + 2
272
+ = 5
273
+ ```
274
+
275
+ The request is allowed because:
276
+
277
+ ```text
278
+ 5 < 10
279
+ ```
280
+
281
+ The Redis Lua script performs the calculation and counter update atomically.
282
+
283
+ ## Why Redis + Lua?
284
+
285
+ The rate limiter performs multiple operations:
286
+
287
+ ```text
288
+ Read current counter
289
+ Read previous counter
290
+ Calculate weighted count
291
+ Check limit
292
+ Increment counter
293
+ Set expiration
294
+ ```
295
+
296
+ These operations need to behave atomically when many requests arrive concurrently.
297
+
298
+ Redis executes the Lua script atomically, preventing race conditions such as multiple concurrent requests all observing the same counter value before incrementing it.
299
+
300
+ ## Redis Key Structure
301
+
302
+ Keys follow this structure:
303
+
304
+ ```text
305
+ {keyPrefix}:{clientKey}:{windowStart}
306
+ ```
307
+
308
+ Example:
309
+
310
+ ```text
311
+ rate-limit:user-123:1757750400000
312
+ ```
313
+
314
+ Previous and current windows use separate Redis keys.
315
+
316
+ ## Testing
317
+
318
+ Run the test suite with:
319
+
320
+ ```bash
321
+ npm test
322
+ ```
323
+
324
+ The project includes tests for:
325
+
326
+ - Basic rate limiting
327
+ - Window expiration
328
+ - Sliding-window behavior
329
+ - Previous-window weighting
330
+ - Concurrent requests
331
+ - Client isolation
332
+ - Redis failure handling
333
+ - Rate-limit headers
334
+ - Configuration validation
335
+ - Public package API
336
+
337
+ ## License
338
+
339
+ MIT
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "sliding-window-counter-rate-limiter",
3
+ "version": "1.0.0",
4
+ "description": "Production-ready Sliding Window Counter rate limiter for Express using Redis and Lua",
5
+ "license": "MIT",
6
+ "author": "Shubham Kakade",
7
+ "type": "commonjs",
8
+ "main": "src/index.js",
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "test": "node --test \"test/**/*.test.js\""
16
+ },
17
+ "keywords": [
18
+ "rate-limit",
19
+ "rate-limiter",
20
+ "express",
21
+ "redis",
22
+ "sliding-window",
23
+ "sliding-window-counter",
24
+ "middleware",
25
+ "api",
26
+ "backend"
27
+ ],
28
+ "dependencies": {
29
+ "redis": "^6.2.1"
30
+ },
31
+ "engines": {
32
+ "node": ">=18"
33
+ }
34
+ }
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ const rateLimiter = require("./middleware");
2
+
3
+ module.exports = {
4
+ rateLimiter,
5
+ };
package/src/limiter.js ADDED
@@ -0,0 +1,46 @@
1
+ async function checkRateLimit({
2
+ redis,
3
+ script,
4
+ currentKey,
5
+ previousKey,
6
+ limit,
7
+ windowMs,
8
+ now,
9
+ currentWindowStart,
10
+ }) {
11
+ const result = await redis.eval(script, {
12
+ keys: [
13
+ currentKey,
14
+ previousKey,
15
+ ],
16
+
17
+ arguments: [
18
+ String(limit),
19
+ String(windowMs),
20
+ String(now),
21
+ String(currentWindowStart),
22
+ ],
23
+ });
24
+
25
+ const [
26
+ allowed,
27
+ currentCount,
28
+ previousCount,
29
+ estimatedCount,
30
+ remaining,
31
+ resetMs,
32
+ ] = result;
33
+
34
+ return {
35
+ allowed: Boolean(allowed),
36
+ currentCount,
37
+ previousCount,
38
+ estimatedCount,
39
+ remaining,
40
+ resetMs,
41
+ };
42
+ }
43
+
44
+ module.exports = {
45
+ checkRateLimit,
46
+ };
@@ -0,0 +1,99 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const { checkRateLimit } = require("./limiter");
5
+
6
+ const { validateOptions } = require("./validate-options");
7
+
8
+ const luaScript = fs.readFileSync(
9
+ path.join(__dirname, "redis/script.lua"),
10
+ "utf8",
11
+ );
12
+
13
+ function rateLimiter({
14
+ redis,
15
+ limit,
16
+ windowMs,
17
+ keyGenerator = (req) => req.ip,
18
+ keyPrefix = "rate-limit",
19
+ redisErrorStrategy = "fail-open",
20
+ }) {
21
+ validateOptions({
22
+ redis,
23
+ limit,
24
+ windowMs,
25
+ keyGenerator,
26
+ keyPrefix,
27
+ redisErrorStrategy,
28
+ });
29
+
30
+ return async function (req, res, next) {
31
+ try {
32
+ const now = Date.now();
33
+
34
+ const currentWindowStart = Math.floor(now / windowMs) * windowMs;
35
+
36
+ const previousWindowStart = currentWindowStart - windowMs;
37
+
38
+ const clientKey = keyGenerator(req);
39
+
40
+ if (typeof clientKey !== "string" || clientKey.trim().length === 0) {
41
+ throw new Error("keyGenerator must return a non-empty string");
42
+ }
43
+
44
+ const currentKey = `${keyPrefix}:${clientKey}:${currentWindowStart}`;
45
+
46
+ const previousKey = `${keyPrefix}:${clientKey}:${previousWindowStart}`;
47
+
48
+ const result = await checkRateLimit({
49
+ redis,
50
+ script: luaScript,
51
+ currentKey,
52
+ previousKey,
53
+ limit,
54
+ windowMs,
55
+ now,
56
+ currentWindowStart,
57
+ });
58
+
59
+ // Headers
60
+ res.setHeader("RateLimit-Limit", limit);
61
+
62
+ res.setHeader("RateLimit-Remaining", Math.max(0, result.remaining));
63
+
64
+ const resetSeconds = Math.ceil(result.resetMs / 1000);
65
+
66
+ res.setHeader("RateLimit-Reset", resetSeconds);
67
+
68
+ // Reject
69
+ if (!result.allowed) {
70
+ res.setHeader("Retry-After", resetSeconds);
71
+
72
+ return res.status(429).json({
73
+ success: false,
74
+ message: "Too many requests",
75
+ });
76
+ }
77
+
78
+ // Allow
79
+ next();
80
+ } catch (error) {
81
+ console.error("Rate limiter Redis error:", error);
82
+
83
+ if (redisErrorStrategy === "fail-open") {
84
+ return next();
85
+ }
86
+
87
+ if (redisErrorStrategy === "fail-closed") {
88
+ return res.status(503).json({
89
+ success: false,
90
+ message: "Rate limiter temporarily unavailable",
91
+ });
92
+ }
93
+
94
+ return next(new Error("Invalid redisErrorStrategy"));
95
+ }
96
+ };
97
+ }
98
+
99
+ module.exports = rateLimiter;
@@ -0,0 +1,75 @@
1
+ local current_key = KEYS[1]
2
+ local previous_key = KEYS[2]
3
+
4
+ local limit = tonumber(ARGV[1])
5
+ local window_ms = tonumber(ARGV[2])
6
+ local now = tonumber(ARGV[3])
7
+ local current_window_start = tonumber(ARGV[4])
8
+
9
+ local current_count =
10
+ tonumber(redis.call("GET", current_key)) or 0
11
+
12
+ local previous_count =
13
+ tonumber(redis.call("GET", previous_key)) or 0
14
+
15
+ local elapsed =
16
+ now - current_window_start
17
+
18
+ local progress =
19
+ elapsed / window_ms
20
+
21
+ local previous_contribution =
22
+ previous_count * (1 - progress)
23
+
24
+ local estimated_count =
25
+ previous_contribution + current_count
26
+
27
+ -- Request rejected
28
+ if estimated_count >= limit then
29
+
30
+ local remaining =
31
+ math.max(0, math.floor(limit - estimated_count))
32
+
33
+ local reset_ms =
34
+ window_ms - elapsed
35
+
36
+ return {
37
+ 0,
38
+ current_count,
39
+ previous_count,
40
+ math.floor(estimated_count),
41
+ remaining,
42
+ reset_ms
43
+ }
44
+ end
45
+
46
+ -- Request allowed
47
+ current_count =
48
+ redis.call("INCR", current_key)
49
+
50
+ redis.call(
51
+ "PEXPIRE",
52
+ current_key,
53
+ window_ms * 2
54
+ )
55
+
56
+ local new_estimated_count =
57
+ estimated_count + 1
58
+
59
+ local remaining =
60
+ math.max(
61
+ 0,
62
+ math.floor(limit - new_estimated_count)
63
+ )
64
+
65
+ local reset_ms =
66
+ window_ms - elapsed
67
+
68
+ return {
69
+ 1,
70
+ current_count,
71
+ previous_count,
72
+ math.floor(new_estimated_count),
73
+ remaining,
74
+ reset_ms
75
+ }
@@ -0,0 +1,36 @@
1
+ function validateOptions({
2
+ redis,
3
+ limit,
4
+ windowMs,
5
+ keyGenerator,
6
+ keyPrefix,
7
+ redisErrorStrategy,
8
+ }) {
9
+ if (!redis) {
10
+ throw new Error("redis is required");
11
+ }
12
+
13
+ if (!Number.isInteger(limit) || limit <= 0) {
14
+ throw new Error("limit must be a positive integer");
15
+ }
16
+
17
+ if (!Number.isInteger(windowMs) || windowMs <= 0) {
18
+ throw new Error("windowMs must be a positive integer");
19
+ }
20
+
21
+ if (typeof keyGenerator !== "function") {
22
+ throw new Error("keyGenerator must be a function");
23
+ }
24
+
25
+ if (typeof keyPrefix !== "string" || keyPrefix.trim().length === 0) {
26
+ throw new Error("keyPrefix must be a non-empty string");
27
+ }
28
+
29
+ if (!["fail-open", "fail-closed"].includes(redisErrorStrategy)) {
30
+ throw new Error('redisErrorStrategy must be "fail-open" or "fail-closed"');
31
+ }
32
+ }
33
+
34
+ module.exports = {
35
+ validateOptions,
36
+ };