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 +21 -0
- package/README.md +339 -0
- package/package.json +34 -0
- package/src/index.js +5 -0
- package/src/limiter.js +46 -0
- package/src/middleware.js +99 -0
- package/src/redis/script.lua +75 -0
- package/src/validate-options.js +36 -0
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
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
|
+
};
|