web-gatekeeper-js 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 +486 -0
- package/package.json +24 -0
- package/src/RateLimiter.js +94 -0
- package/src/Throttler.js +55 -0
- package/src/algorithms/SlidingWindowCounter.js +29 -0
- package/src/algorithms/TokenBucket.js +32 -0
- package/src/index.js +2 -0
- package/src/scripts/slidingWindow.lua.js +62 -0
- package/src/scripts/throttler.lua.js +34 -0
- package/src/scripts/tokenBucket.lua.js +46 -0
- package/src/store/RedisStore.js +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
# gatekeeper-js
|
|
2
|
+
|
|
3
|
+
A high-performance, Redis-powered rate limiter and throttler for Node.js applications.
|
|
4
|
+
Built for distributed systems, horizontally scalable architectures, and high concurrency environments.
|
|
5
|
+
|
|
6
|
+
Supports:
|
|
7
|
+
|
|
8
|
+
- Sliding Window Counter rate limiting
|
|
9
|
+
- Token Bucket throttling
|
|
10
|
+
- Atomic operations using Lua scripts
|
|
11
|
+
- Distributed environments using Redis
|
|
12
|
+
- Zero race conditions
|
|
13
|
+
- High concurrency handling
|
|
14
|
+
- Express, Fastify, NestJS, Koa, and custom Node.js servers
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Features
|
|
19
|
+
|
|
20
|
+
- โก Extremely fast Redis-based implementation
|
|
21
|
+
- ๐ Atomic operations using Redis Lua scripts
|
|
22
|
+
- ๐ Works across multiple servers/instances
|
|
23
|
+
- ๐ Horizontally scalable
|
|
24
|
+
- ๐ง Supports burst traffic handling
|
|
25
|
+
- ๐ซ Prevents race conditions in concurrent environments
|
|
26
|
+
- ๐ชถ Lightweight and dependency minimal
|
|
27
|
+
- ๐ง Fully configurable
|
|
28
|
+
- ๐งต Safe under heavy parallel requests
|
|
29
|
+
- โป๏ธ Reusable Redis connections
|
|
30
|
+
- ๐ฆ TypeScript support
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
# Why Redis?
|
|
35
|
+
|
|
36
|
+
Traditional in-memory rate limiters work only on a single server instance.
|
|
37
|
+
|
|
38
|
+
That becomes a problem when your application is deployed across:
|
|
39
|
+
|
|
40
|
+
- Multiple containers
|
|
41
|
+
- Multiple Node.js processes
|
|
42
|
+
- Kubernetes clusters
|
|
43
|
+
- Load balanced servers
|
|
44
|
+
- Microservices
|
|
45
|
+
|
|
46
|
+
Redis acts as a centralized shared datastore, allowing every instance of your application to enforce the same limits consistently.
|
|
47
|
+
|
|
48
|
+
This package uses Redis for:
|
|
49
|
+
|
|
50
|
+
- Shared state management
|
|
51
|
+
- Atomic counters
|
|
52
|
+
- Distributed synchronization
|
|
53
|
+
- Fast in-memory performance
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
# Why Lua Scripts?
|
|
58
|
+
|
|
59
|
+
Redis commands executed separately can lead to race conditions.
|
|
60
|
+
|
|
61
|
+
Example problem:
|
|
62
|
+
|
|
63
|
+
Two requests arrive simultaneously.
|
|
64
|
+
|
|
65
|
+
Both requests:
|
|
66
|
+
|
|
67
|
+
1. Read current token count
|
|
68
|
+
2. Both think tokens are available
|
|
69
|
+
3. Both consume tokens
|
|
70
|
+
4. Limit gets bypassed
|
|
71
|
+
|
|
72
|
+
This package avoids that entirely using Redis Lua scripts.
|
|
73
|
+
|
|
74
|
+
Lua scripts run atomically inside Redis, meaning:
|
|
75
|
+
|
|
76
|
+
- No other Redis command can interrupt execution
|
|
77
|
+
- Read + update operations happen together
|
|
78
|
+
- Concurrent requests remain safe
|
|
79
|
+
- No locks are required
|
|
80
|
+
|
|
81
|
+
This guarantees correctness even under massive traffic spikes.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
# Algorithms Used
|
|
86
|
+
|
|
87
|
+
## Sliding Window Counter (Rate Limiter)
|
|
88
|
+
|
|
89
|
+
Used by `RateLimiter`.
|
|
90
|
+
|
|
91
|
+
Tracks requests within a moving time window.
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
|
|
95
|
+
- Limit: `100 requests`
|
|
96
|
+
- Window: `60 seconds`
|
|
97
|
+
|
|
98
|
+
The limiter continuously tracks requests within the last 60 seconds instead of resetting abruptly like fixed window algorithms.
|
|
99
|
+
|
|
100
|
+
### Benefits
|
|
101
|
+
|
|
102
|
+
- Smoother limiting
|
|
103
|
+
- More accurate than fixed windows
|
|
104
|
+
- Prevents sudden request bursts at window boundaries
|
|
105
|
+
- Better user experience
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Token Bucket (Throttler)
|
|
110
|
+
|
|
111
|
+
Used by `Throttler`.
|
|
112
|
+
|
|
113
|
+
Tokens refill gradually over time.
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
|
|
117
|
+
- Bucket size: `10`
|
|
118
|
+
- Refill rate: `2 tokens/sec`
|
|
119
|
+
|
|
120
|
+
If the bucket has tokens available, requests are allowed immediately.
|
|
121
|
+
|
|
122
|
+
If not:
|
|
123
|
+
|
|
124
|
+
- Requests are delayed/rejected
|
|
125
|
+
- Burst traffic is controlled gracefully
|
|
126
|
+
|
|
127
|
+
### Benefits
|
|
128
|
+
|
|
129
|
+
- Handles traffic spikes efficiently
|
|
130
|
+
- Allows short bursts
|
|
131
|
+
- Smooth request flow
|
|
132
|
+
- Ideal for APIs and real-time systems
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
# Installation
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
npm install gatekeeper-js ioredis
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
# Quick Start
|
|
145
|
+
|
|
146
|
+
## Rate Limiter Example
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
import { RateLimiter } from 'gatekeeper-js'
|
|
150
|
+
import Redis from 'ioredis'
|
|
151
|
+
|
|
152
|
+
const redis = new Redis()
|
|
153
|
+
|
|
154
|
+
const limiter = new RateLimiter({
|
|
155
|
+
redisClient : redis,
|
|
156
|
+
windowSize : 60000,
|
|
157
|
+
limit : 100,
|
|
158
|
+
maxToken : 10,
|
|
159
|
+
refillRate : 2
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
app.use(async (req, res, next) => {
|
|
163
|
+
const result = await limiter.consume(req.ip)
|
|
164
|
+
|
|
165
|
+
if (!result.allowed) {
|
|
166
|
+
return res.status(429).json({
|
|
167
|
+
error: result.reason
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
next()
|
|
172
|
+
})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Throttler Example
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
import { Throttler } from 'gatekeeper-js'
|
|
181
|
+
import Redis from 'ioredis'
|
|
182
|
+
|
|
183
|
+
const redis = new Redis()
|
|
184
|
+
|
|
185
|
+
const throttler = new Throttler({
|
|
186
|
+
redisClient : redis,
|
|
187
|
+
refillRate : 2,
|
|
188
|
+
maxWait : 5000
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
app.use(async (req, res, next) => {
|
|
192
|
+
const result = await throttler.consume(req.ip)
|
|
193
|
+
|
|
194
|
+
if (!result.allowed) {
|
|
195
|
+
return res.status(429).json({
|
|
196
|
+
error: 'Too Many Requests'
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
next()
|
|
201
|
+
})
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
# API
|
|
207
|
+
|
|
208
|
+
# RateLimiter
|
|
209
|
+
|
|
210
|
+
## Constructor
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
new RateLimiter(options)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Options
|
|
217
|
+
|
|
218
|
+
| Option | Type | Required | Description |
|
|
219
|
+
|---|---|---|---|
|
|
220
|
+
| redisClient | Redis | Yes | ioredis client instance |
|
|
221
|
+
| windowSize | number | Yes | Time window in milliseconds |
|
|
222
|
+
| limit | number | Yes | Maximum requests allowed per window |
|
|
223
|
+
| maxToken | number | No | Maximum burst capacity |
|
|
224
|
+
| refillRate | number | No | Token refill rate per second |
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## consume(key)
|
|
229
|
+
|
|
230
|
+
Consumes one request for a given identifier.
|
|
231
|
+
|
|
232
|
+
```js
|
|
233
|
+
const result = await limiter.consume('user-id')
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Response
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
{
|
|
240
|
+
allowed: true,
|
|
241
|
+
remaining: 42,
|
|
242
|
+
resetTime: 1716200000000
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Response Fields
|
|
247
|
+
|
|
248
|
+
| Field | Description |
|
|
249
|
+
|---|---|
|
|
250
|
+
| allowed | Whether request is allowed |
|
|
251
|
+
| remaining | Remaining requests/tokens |
|
|
252
|
+
| resetTime | Unix timestamp when limit resets |
|
|
253
|
+
| reason | Rejection reason if blocked |
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
# Throttler
|
|
258
|
+
|
|
259
|
+
## Constructor
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
new Throttler(options)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Options
|
|
266
|
+
|
|
267
|
+
| Option | Type | Required | Description |
|
|
268
|
+
|---|---|---|---|
|
|
269
|
+
| redisClient | Redis | Yes | ioredis client instance |
|
|
270
|
+
| refillRate | number | Yes | Tokens added per second |
|
|
271
|
+
| maxWait | number | No | Maximum wait time before rejection |
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## consume(key)
|
|
276
|
+
|
|
277
|
+
```js
|
|
278
|
+
const result = await throttler.consume('user-id')
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Response
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
{
|
|
285
|
+
allowed: true,
|
|
286
|
+
retryAfter: 0
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
# Scalability
|
|
293
|
+
|
|
294
|
+
This package is designed for distributed systems.
|
|
295
|
+
|
|
296
|
+
Because Redis stores the state centrally:
|
|
297
|
+
|
|
298
|
+
- Multiple Node.js instances share the same limits
|
|
299
|
+
- Limits remain consistent across deployments
|
|
300
|
+
- Works seamlessly behind load balancers
|
|
301
|
+
- Suitable for microservices and Kubernetes
|
|
302
|
+
|
|
303
|
+
Example architecture:
|
|
304
|
+
|
|
305
|
+
```text
|
|
306
|
+
Client Requests
|
|
307
|
+
โ
|
|
308
|
+
Load Balancer
|
|
309
|
+
โ
|
|
310
|
+
โโโโโโโโโโโโโโโ
|
|
311
|
+
โ Node App 1 โ
|
|
312
|
+
โโโโโโโโโโโโโโโค
|
|
313
|
+
โ Node App 2 โ
|
|
314
|
+
โโโโโโโโโโโโโโโค
|
|
315
|
+
โ Node App 3 โ
|
|
316
|
+
โโโโโโโโโโโโโโโ
|
|
317
|
+
โ
|
|
318
|
+
Redis
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
All application instances communicate with the same Redis server.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
# Concurrency Safety
|
|
326
|
+
|
|
327
|
+
This package is safe under heavy concurrent traffic.
|
|
328
|
+
|
|
329
|
+
Redis Lua scripts ensure:
|
|
330
|
+
|
|
331
|
+
- Atomic execution
|
|
332
|
+
- No partial updates
|
|
333
|
+
- No inconsistent counters
|
|
334
|
+
- No race conditions
|
|
335
|
+
|
|
336
|
+
Even if thousands of requests arrive simultaneously, limits remain accurate.
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
# Performance
|
|
341
|
+
|
|
342
|
+
Redis operations are extremely fast because Redis stores data in memory.
|
|
343
|
+
|
|
344
|
+
The package is optimized to:
|
|
345
|
+
|
|
346
|
+
- Minimize Redis calls
|
|
347
|
+
- Use atomic Lua execution
|
|
348
|
+
- Reduce network overhead
|
|
349
|
+
- Handle high throughput efficiently
|
|
350
|
+
|
|
351
|
+
Suitable for:
|
|
352
|
+
|
|
353
|
+
- Public APIs
|
|
354
|
+
- Authentication systems
|
|
355
|
+
- Login protection
|
|
356
|
+
- Payment APIs
|
|
357
|
+
- WebSocket gateways
|
|
358
|
+
- Real-time applications
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
# Redis Compatibility
|
|
363
|
+
|
|
364
|
+
Compatible with:
|
|
365
|
+
|
|
366
|
+
- Redis 6+
|
|
367
|
+
- Redis 7+
|
|
368
|
+
- Redis Cluster
|
|
369
|
+
- Redis Cloud providers
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
# Framework Support
|
|
374
|
+
|
|
375
|
+
Works with any Node.js framework:
|
|
376
|
+
|
|
377
|
+
- Express
|
|
378
|
+
- Fastify
|
|
379
|
+
- NestJS
|
|
380
|
+
- Koa
|
|
381
|
+
- Hono
|
|
382
|
+
- Native HTTP servers
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
# Error Handling
|
|
387
|
+
|
|
388
|
+
Example:
|
|
389
|
+
|
|
390
|
+
```js
|
|
391
|
+
try {
|
|
392
|
+
const result = await limiter.consume(req.ip)
|
|
393
|
+
|
|
394
|
+
if (!result.allowed) {
|
|
395
|
+
return res.status(429).json({
|
|
396
|
+
error: result.reason
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
next()
|
|
401
|
+
} catch (error) {
|
|
402
|
+
console.error(error)
|
|
403
|
+
|
|
404
|
+
return res.status(500).json({
|
|
405
|
+
error: 'Internal Server Error'
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
# Best Practices
|
|
413
|
+
|
|
414
|
+
## Use Stable Keys
|
|
415
|
+
|
|
416
|
+
Good examples:
|
|
417
|
+
|
|
418
|
+
```js
|
|
419
|
+
req.ip
|
|
420
|
+
user.id
|
|
421
|
+
apiKey
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Avoid random or frequently changing keys.
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## Reuse Redis Connections
|
|
429
|
+
|
|
430
|
+
Create one shared Redis client instance and reuse it across the application.
|
|
431
|
+
|
|
432
|
+
```js
|
|
433
|
+
const redis = new Redis()
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## Configure Reasonable Limits
|
|
439
|
+
|
|
440
|
+
| Use Case | Suggested Limit |
|
|
441
|
+
|---|---|
|
|
442
|
+
| Login API | 5 requests/min |
|
|
443
|
+
| Public API | 100 requests/min |
|
|
444
|
+
| OTP Verification | 3 requests/5 min |
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
# License
|
|
449
|
+
|
|
450
|
+
ISC License
|
|
451
|
+
|
|
452
|
+
Permission to use, modify, and distribute this software for any purpose with or without fee is hereby granted.
|
|
453
|
+
|
|
454
|
+
See the `LICENSE` file for full details.
|
|
455
|
+
|
|
456
|
+
---
|
|
457
|
+
|
|
458
|
+
# Contributing
|
|
459
|
+
|
|
460
|
+
Contributions are welcome.
|
|
461
|
+
|
|
462
|
+
Feel free to:
|
|
463
|
+
|
|
464
|
+
- Open issues
|
|
465
|
+
- Suggest improvements
|
|
466
|
+
- Submit pull requests
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
# Roadmap
|
|
471
|
+
|
|
472
|
+
Planned features:
|
|
473
|
+
|
|
474
|
+
- Redis Cluster optimizations
|
|
475
|
+
- Sliding log algorithm
|
|
476
|
+
- Fixed window limiter
|
|
477
|
+
- Rate limit headers
|
|
478
|
+
- Automatic retries
|
|
479
|
+
- Memory fallback store
|
|
480
|
+
- Metrics integration
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
484
|
+
# Author
|
|
485
|
+
|
|
486
|
+
Built for modern distributed Node.js applications using Redis and Lua for correctness, scalability, and performance.
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "web-gatekeeper-js",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Redis based rate limiter and throttler using sliding window and token bucket algorithms",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"rate-limiter",
|
|
9
|
+
"throttler",
|
|
10
|
+
"redis",
|
|
11
|
+
"sliding-window",
|
|
12
|
+
"token-bucket",
|
|
13
|
+
"express",
|
|
14
|
+
"nodejs"
|
|
15
|
+
],
|
|
16
|
+
"author": "Naveen Kushwaha",
|
|
17
|
+
"license": "ISC",
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"ioredis": "^5.10.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"ioredis": "^5.10.1"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { slidingWindowScript } from "./scripts/slidingWindow.lua";
|
|
2
|
+
import { tokenBucket } from "./scripts/tokenBucket.lua";
|
|
3
|
+
import { RedisStore } from "./store/RedisStore";
|
|
4
|
+
|
|
5
|
+
export class RateLimiter {
|
|
6
|
+
#store;
|
|
7
|
+
#windowSize;
|
|
8
|
+
#limit;
|
|
9
|
+
#maxToken;
|
|
10
|
+
#refillRate;
|
|
11
|
+
|
|
12
|
+
constructor({ redisClient, windowSize, limit, maxToken, refillRate }) {
|
|
13
|
+
if (!redisClient) throw new Error("redisClient is required");
|
|
14
|
+
if (!windowSize) throw new Error("windowSize is required");
|
|
15
|
+
if (!limit) throw new Error("limit is required");
|
|
16
|
+
if (!maxToken) throw new Error("maxToken is required");
|
|
17
|
+
if (!refillRate) throw new Error("refillRate is required");
|
|
18
|
+
|
|
19
|
+
this.#store = new RedisStore(redisClient);
|
|
20
|
+
this.#windowSize = windowSize;
|
|
21
|
+
this.#limit = limit;
|
|
22
|
+
this.#maxToken = maxToken;
|
|
23
|
+
this.#refillRate = refillRate;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async consume(identifier){
|
|
27
|
+
const now = Date.now()
|
|
28
|
+
|
|
29
|
+
const slidingResult = await this.#runSlidingWindow(identifier, now)
|
|
30
|
+
if(!slidingResult.allowed){
|
|
31
|
+
return {
|
|
32
|
+
allowed: false,
|
|
33
|
+
reason: 'rate_limit_exceeded',
|
|
34
|
+
resetAfter: slidingResult.resetAfter
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const bucketResult = await this.#runTokenBucket(identifier, now)
|
|
39
|
+
if(!bucketResult.allowed){
|
|
40
|
+
return {
|
|
41
|
+
allowed: false,
|
|
42
|
+
reason: 'burst_limit_exceeded',
|
|
43
|
+
retryAfter: bucketResult.retryAfter
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
allowed : true,
|
|
49
|
+
remaining : slidingResult.remaining,
|
|
50
|
+
tokensRemaining : bucketResult.tokensLeft
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async #runSlidingWindow(identifier,now){
|
|
55
|
+
const key = `rl:sliding:${identifier}`
|
|
56
|
+
const ttl = Math.ceil((this.#windowSize / 1000) * 2)
|
|
57
|
+
|
|
58
|
+
const result = await this.#store.evalScript(
|
|
59
|
+
slidingWindowScript,
|
|
60
|
+
key,
|
|
61
|
+
this.#windowSize,
|
|
62
|
+
this.#limit,
|
|
63
|
+
now,
|
|
64
|
+
ttl
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
allowed : result[0] === 1,
|
|
69
|
+
current : result[1],
|
|
70
|
+
remaining : result[2],
|
|
71
|
+
resetAfter: result[3]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async #runTokenBucket(identifier, now) {
|
|
76
|
+
const key = `rl:bucket:${identifier}`
|
|
77
|
+
const ttl = Math.max(Math.ceil(this.#maxToken / this.#refillRate) * 2, 60)
|
|
78
|
+
|
|
79
|
+
const result = await this.#store.evalScript(
|
|
80
|
+
tokenBucketScript,
|
|
81
|
+
key,
|
|
82
|
+
now,
|
|
83
|
+
this.#maxToken,
|
|
84
|
+
this.#refillRate,
|
|
85
|
+
ttl
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
allowed : result[0] === 1,
|
|
90
|
+
tokensLeft : result[1],
|
|
91
|
+
retryAfter : result[2]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/Throttler.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/Throttler.js
|
|
2
|
+
|
|
3
|
+
import { throttlerScript } from './scripts/throttler.lua.js'
|
|
4
|
+
import { RedisStore } from './store/RedisStore.js'
|
|
5
|
+
|
|
6
|
+
export class Throttler {
|
|
7
|
+
#store
|
|
8
|
+
#refillRate
|
|
9
|
+
#maxWait
|
|
10
|
+
|
|
11
|
+
constructor({ redisClient, refillRate, maxWait }) {
|
|
12
|
+
if (!redisClient) throw new Error('redisClient is required')
|
|
13
|
+
if (!refillRate) throw new Error('refillRate is required')
|
|
14
|
+
if (!maxWait) throw new Error('maxWait is required')
|
|
15
|
+
|
|
16
|
+
this.#store = new RedisStore(redisClient)
|
|
17
|
+
this.#refillRate = refillRate
|
|
18
|
+
this.#maxWait = maxWait
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async consume(identifier) {
|
|
22
|
+
const now = Date.now()
|
|
23
|
+
const key = `rl:throttler:${identifier}`
|
|
24
|
+
const ttl = 60
|
|
25
|
+
|
|
26
|
+
const result = await this.#store.evalScript(
|
|
27
|
+
throttlerScript,
|
|
28
|
+
key,
|
|
29
|
+
now,
|
|
30
|
+
this.#refillRate,
|
|
31
|
+
this.#maxWait,
|
|
32
|
+
ttl
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const allowed = result[0] === 1
|
|
36
|
+
const waitTime = result[1]
|
|
37
|
+
|
|
38
|
+
if (!allowed) {
|
|
39
|
+
return {
|
|
40
|
+
allowed : false,
|
|
41
|
+
retryAfter : (waitTime / 1000).toFixed(2) + 's'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (waitTime > 0) {
|
|
46
|
+
await this.#delay(waitTime)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { allowed: true, waitTime }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#delay(ms) {
|
|
53
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const slidingWindow = async (req, res, next) => {
|
|
2
|
+
const userId = req.ip
|
|
3
|
+
const windowSize = 60000
|
|
4
|
+
const limit = 5
|
|
5
|
+
const now = Date.now()
|
|
6
|
+
const ttl = (windowSize / 1000) * 2
|
|
7
|
+
|
|
8
|
+
const result = await redisClient.eval(
|
|
9
|
+
slidingWindowScript,
|
|
10
|
+
1,
|
|
11
|
+
`rl:Sliding:user:${userId}`,
|
|
12
|
+
windowSize,
|
|
13
|
+
limit,
|
|
14
|
+
now,
|
|
15
|
+
ttl
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
console.log({
|
|
19
|
+
allowed : result[0] === 1,
|
|
20
|
+
currentCount : result[1],
|
|
21
|
+
remaining : result[2]
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
if (result[0] === 0) {
|
|
25
|
+
return res.status(429).json({ message: "Too Many Requests" })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return next()
|
|
29
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const tokenBucket = async (req, res, next) => {
|
|
2
|
+
const userId = req.ip
|
|
3
|
+
const maxToken = 5
|
|
4
|
+
const refillRate = 0.5
|
|
5
|
+
const now = Date.now()
|
|
6
|
+
const ttl = Math.max(Math.ceil(maxToken / refillRate) * 2, 60)
|
|
7
|
+
|
|
8
|
+
const result = await redisClient.eval(
|
|
9
|
+
tokenBucketScript,
|
|
10
|
+
1,
|
|
11
|
+
`rl:tokenBucket:user:${userId}`,
|
|
12
|
+
now,
|
|
13
|
+
maxToken,
|
|
14
|
+
refillRate,
|
|
15
|
+
ttl
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
console.log({
|
|
19
|
+
allowed : result[0] === 1,
|
|
20
|
+
tokensLeft : result[1],
|
|
21
|
+
retryAfter : result[2]
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
if (result[0] === 0) {
|
|
25
|
+
return res.status(429).json({
|
|
26
|
+
message : "Too Many Requests",
|
|
27
|
+
retryAfter : `${result[2]}s`
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return next()
|
|
32
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const slidingWindowScript = `
|
|
2
|
+
local key = KEYS[1]
|
|
3
|
+
local windowSize = tonumber(ARGV[1])
|
|
4
|
+
local limit = tonumber(ARGV[2])
|
|
5
|
+
local now = tonumber(ARGV[3])
|
|
6
|
+
local ttl = tonumber(ARGV[4])
|
|
7
|
+
|
|
8
|
+
local windowStart = math.floor(now / windowSize) * windowSize
|
|
9
|
+
|
|
10
|
+
local storedWindowStart = tonumber(redis.call('HGET', key, 'windowStart') or 0)
|
|
11
|
+
local currentCount = tonumber(redis.call('HGET', key, 'currentCount') or 0)
|
|
12
|
+
local previousCount = tonumber(redis.call('HGET', key, 'previousCount') or 0)
|
|
13
|
+
|
|
14
|
+
-- resetAfter calculated once, used in all returns
|
|
15
|
+
local resetAfter = math.ceil((windowSize - (now - windowStart)) / 1000)
|
|
16
|
+
|
|
17
|
+
-- first time user
|
|
18
|
+
if storedWindowStart == 0 then
|
|
19
|
+
redis.call('HSET', key,
|
|
20
|
+
'windowStart', windowStart,
|
|
21
|
+
'currentCount', 1,
|
|
22
|
+
'previousCount', 0
|
|
23
|
+
)
|
|
24
|
+
redis.call('EXPIRE', key, ttl)
|
|
25
|
+
return { 1, 1, limit - 1, resetAfter } -- โ 4 values
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
local windowsPassed = math.floor((windowStart - storedWindowStart) / windowSize)
|
|
29
|
+
|
|
30
|
+
local newPreviousCount = previousCount
|
|
31
|
+
local newCurrentCount = currentCount
|
|
32
|
+
|
|
33
|
+
if windowsPassed > 1 then
|
|
34
|
+
newPreviousCount = 0
|
|
35
|
+
newCurrentCount = 0
|
|
36
|
+
elseif windowsPassed == 1 then
|
|
37
|
+
newPreviousCount = currentCount
|
|
38
|
+
newCurrentCount = 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
local elapsedTime = (now - windowStart) / 1000
|
|
42
|
+
local overlap = 1 - (elapsedTime / (windowSize / 1000))
|
|
43
|
+
local effectiveCount = (overlap * newPreviousCount) + newCurrentCount
|
|
44
|
+
|
|
45
|
+
-- blocked
|
|
46
|
+
if effectiveCount >= limit then
|
|
47
|
+
return { 0, newCurrentCount, 0, resetAfter } -- โ 4 values
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
-- allowed
|
|
51
|
+
newCurrentCount = newCurrentCount + 1
|
|
52
|
+
|
|
53
|
+
redis.call('HSET', key,
|
|
54
|
+
'windowStart', windowStart,
|
|
55
|
+
'currentCount', newCurrentCount,
|
|
56
|
+
'previousCount', newPreviousCount
|
|
57
|
+
)
|
|
58
|
+
redis.call('EXPIRE', key, ttl)
|
|
59
|
+
|
|
60
|
+
local remaining = math.floor(limit - effectiveCount - 1)
|
|
61
|
+
return { 1, newCurrentCount, remaining, resetAfter } -- โ 4 values
|
|
62
|
+
`
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const throttler = `
|
|
2
|
+
local key = KEYS[1]
|
|
3
|
+
local now = tonumber(ARGV[1])
|
|
4
|
+
local refillRate = tonumber(ARGV[2])
|
|
5
|
+
local maxWait = tonumber(ARGV[3])
|
|
6
|
+
local ttl = tonumber(ARGV[4])
|
|
7
|
+
|
|
8
|
+
-- safely read nextAllowedTime
|
|
9
|
+
local raw = redis.call('HGET', key, 'nextAllowedTime')
|
|
10
|
+
local nextAllowedTime = raw and tonumber(raw) or now
|
|
11
|
+
|
|
12
|
+
-- calculate wait time
|
|
13
|
+
local waitTime = nextAllowedTime - now
|
|
14
|
+
|
|
15
|
+
-- reject if queue too full
|
|
16
|
+
if waitTime > maxWait then
|
|
17
|
+
return { 0, waitTime }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
-- calculate new nextAllowedTime
|
|
21
|
+
local newNextAllowedTime
|
|
22
|
+
if waitTime <= 0 then
|
|
23
|
+
newNextAllowedTime = now + (1000 / refillRate)
|
|
24
|
+
else
|
|
25
|
+
newNextAllowedTime = nextAllowedTime + (1000 / refillRate)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
-- save and set TTL
|
|
29
|
+
redis.call('HSET', key, 'nextAllowedTime', newNextAllowedTime)
|
|
30
|
+
redis.call('EXPIRE', key, ttl)
|
|
31
|
+
|
|
32
|
+
-- return allowed + waitTime so Node.js knows how long to delay
|
|
33
|
+
return { 1, math.max(waitTime, 0) }
|
|
34
|
+
`
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const tokenBucketScript = `
|
|
2
|
+
local key = KEYS[1]
|
|
3
|
+
local now = tonumber(ARGV[1])
|
|
4
|
+
local maxToken = tonumber(ARGV[2])
|
|
5
|
+
local refillRate = tonumber(ARGV[3])
|
|
6
|
+
local ttl = tonumber(ARGV[4])
|
|
7
|
+
|
|
8
|
+
-- read stored state (same as your hGetAll)
|
|
9
|
+
local lastReqTime = tonumber(redis.call('HGET', key, 'time') or now)
|
|
10
|
+
local tokenLeft = tonumber(redis.call('HGET', key, 'tokenLeft') or maxToken)
|
|
11
|
+
|
|
12
|
+
-- first time user (same as your empty check)
|
|
13
|
+
if lastReqTime == nil or tokenLeft == nil then
|
|
14
|
+
redis.call('HSET', key,
|
|
15
|
+
'time', now,
|
|
16
|
+
'tokenLeft', maxToken - 1
|
|
17
|
+
)
|
|
18
|
+
redis.call('EXPIRE', key, ttl)
|
|
19
|
+
return { 1, maxToken - 1 }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
-- calculate refill (same as your timeElapsed and updatedToken)
|
|
23
|
+
local timeElapsed = (now - lastReqTime) / 1000
|
|
24
|
+
local updatedToken = math.min(tokenLeft + (timeElapsed * refillRate), maxToken)
|
|
25
|
+
|
|
26
|
+
-- block if not enough tokens (same as your updatedToken < 1)
|
|
27
|
+
if updatedToken < 1 then
|
|
28
|
+
redis.call('HSET', key,
|
|
29
|
+
'time', now,
|
|
30
|
+
'tokenLeft', updatedToken
|
|
31
|
+
)
|
|
32
|
+
redis.call('EXPIRE', key, ttl)
|
|
33
|
+
|
|
34
|
+
local retryAfter = math.ceil((1 - updatedToken) / refillRate)
|
|
35
|
+
return { 0, 0, retryAfter }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
-- allow โ consume 1 token and save (same as your hSet)
|
|
39
|
+
redis.call('HSET', key,
|
|
40
|
+
'time', now,
|
|
41
|
+
'tokenLeft', updatedToken - 1
|
|
42
|
+
)
|
|
43
|
+
redis.call('EXPIRE', key, ttl)
|
|
44
|
+
|
|
45
|
+
return { 1, updatedToken - 1, 0 }
|
|
46
|
+
`
|