express-adaptive-rate-limiter 1.0.0 → 2.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 +250 -16
- package/dist/algorithms/baseAlgorithm.js +1 -1
- package/dist/algorithms/fixedWindow.js +1 -1
- package/dist/algorithms/leakyBucket.js +1 -1
- package/dist/algorithms/slidingWindowCounter.js +1 -1
- package/dist/algorithms/slidingWindowLog.js +1 -1
- package/dist/algorithms/tokenBucket.js +1 -1
- package/dist/core/blockedKeys.js +1 -0
- package/dist/core/coreMiddleware.js +1 -0
- package/dist/core/limiters/clusterLimiter.js +16 -0
- package/dist/core/limiters/dynamoDbLimiter.js +1 -0
- package/dist/core/limiters/etcdLimiter.js +1 -0
- package/dist/core/limiters/memcachedLimiter.js +1 -0
- package/dist/core/limiters/memoryLimiter.js +1 -0
- package/dist/core/limiters/mongoLimiter.js +1 -0
- package/dist/core/limiters/mongooseLimiter.js +1 -0
- package/dist/core/limiters/mySqlLimiter.js +12 -0
- package/dist/core/limiters/nodeClusterLimiter.js +1 -0
- package/dist/core/limiters/postgresLimiter.js +13 -0
- package/dist/core/limiters/prismaLimiter.js +24 -0
- package/dist/core/limiters/redisLimiter.js +16 -0
- package/dist/core/limiters/sqliteLimiter.js +13 -0
- package/dist/core/limiters/valkeyLimiter.js +16 -0
- package/dist/core/rateLimiter.js +1 -0
- package/dist/core/rateLimiterRes.js +1 -0
- package/dist/core/rateLimiterStore.js +1 -0
- package/dist/core/wrappers/blackAndWhiteLimiter.js +1 -0
- package/dist/core/wrappers/burstyRateLimiter.js +1 -0
- package/dist/core/wrappers/rateLimiterQueue.js +1 -0
- package/dist/core/wrappers/rateLimiterUnion.js +1 -0
- package/dist/core/wrappers/timeoutLimiter.js +1 -0
- package/dist/index.js +1 -1
- package/dist/middleware/expressMiddleware.js +1 -1
- package/dist/middleware/headers.js +1 -1
- package/dist/rateLimit.js +1 -1
- package/dist/rateLimitManager.js +1 -1
- package/dist/stores/baseStore.js +1 -1
- package/dist/stores/luaScripts.js +379 -1
- package/dist/stores/memoryStore.js +1 -1
- package/dist/stores/redisStore.js +1 -1
- package/dist/types/index.d.ts +507 -0
- package/dist/utils/errors.js +1 -1
- package/dist/utils/graphQLParser.js +1 -1
- package/dist/utils/ipUtils.js +1 -1
- package/dist/utils/keyGenerator.js +1 -1
- package/dist/utils/redisClient.js +1 -0
- package/dist/utils/retryAfter.js +1 -1
- package/dist/utils/skipUtils.js +1 -1
- package/dist/utils/time.js +1 -1
- package/dist/utils/validation.js +1 -1
- package/package.json +67 -4
package/README.md
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
# express-adaptive-rate-limiter
|
|
2
2
|
|
|
3
|
-
Production-ready
|
|
3
|
+
Production-ready rate limiting for Node.js.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The package ships **two tiers**:
|
|
6
|
+
|
|
7
|
+
- **Core tier** — framework-agnostic limiters with a `consume()` / `get()` / `set()` / `block()` API, backed by **Memory, Redis, Mongo, Mongoose, MySQL, Postgres, SQLite, Prisma, Memcached, DynamoDB, Valkey, etcd** and **Redis Cluster**.
|
|
8
|
+
- **Express tier** — middleware built on **Fixed Window, Sliding Window Log, Sliding Window Counter, Token Bucket** and **Leaky Bucket** algorithms, with atomic Redis Lua scripts, fail-open/fail-closed handling, RFC-compliant standard headers, GraphQL root field detection, dynamic request costs, multi-criteria skip rules and full TypeScript support.
|
|
9
|
+
|
|
10
|
+
Every backend in the core tier is atomic: the counter increment and the window creation happen in a single round trip, so several processes can share one limit safely.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## What's new in 2.0.0
|
|
15
|
+
|
|
16
|
+
**New: the framework-agnostic core tier.** 13 storage backends (Memory, Redis, Valkey, Redis Cluster, Mongo, Mongoose, DynamoDB, MySQL, Postgres, SQLite, Prisma, Memcached, etcd) with a unified points-based API, plus the `Queue`, `Union`, `Bursty`, `BlackAndWhite` and `Timeout` wrappers. See [Core Tier](#core-tier-framework-agnostic-limiters).
|
|
17
|
+
|
|
18
|
+
**Behavior change: the fixed window is anchored on the first request of a key.** In 1.0.0 the window was aligned to the wall clock, so a client that straddled a minute boundary could consume two full windows in a few milliseconds and exceed its configured limit. The window now starts with the first request of a key and lasts `windowMs`. `RateLimit-Reset` now reports the real time until the window ends instead of the next clock boundary.
|
|
19
|
+
|
|
20
|
+
**Performance.** The memory store evicts the least recently used key in O(1) instead of scanning the whole keyspace, IP whitelists and header-based skip rules are compiled once and cached, and Redis key listing uses `SCAN` instead of the blocking `KEYS`.
|
|
21
|
+
|
|
22
|
+
**Redis client support.** A single adapter now drives `ioredis`, `node-redis` v3 and `node-redis` v4, detected at runtime, so the same code works with every supported client.
|
|
6
23
|
|
|
7
24
|
---
|
|
8
25
|
|
|
@@ -14,23 +31,27 @@ npm install express-adaptive-rate-limiter
|
|
|
14
31
|
|
|
15
32
|
### Optional Peer Dependencies
|
|
16
33
|
|
|
17
|
-
Install only
|
|
18
|
-
|
|
19
|
-
- **Redis
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
- **
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
Install only the driver for the backend you use:
|
|
35
|
+
|
|
36
|
+
- **Redis**: `npm install ioredis` or `npm install redis`
|
|
37
|
+
- **MongoDB**: `npm install mongodb`
|
|
38
|
+
- **Mongoose**: `npm install mongoose`
|
|
39
|
+
- **MySQL**: `npm install mysql2`
|
|
40
|
+
- **PostgreSQL**: `npm install pg`
|
|
41
|
+
- **SQLite**: `npm install better-sqlite3` (Node 22.5+ can use the built-in `node:sqlite`)
|
|
42
|
+
- **Prisma**: `npm install @prisma/client`
|
|
43
|
+
- **Memcached**: `npm install memjs` or `npm install memcached`
|
|
44
|
+
- **DynamoDB**: `npm install @aws-sdk/client-dynamodb`
|
|
45
|
+
- **Valkey**: `npm install iovalkey` or `npm install @valkey/valkey-glide`
|
|
46
|
+
- **etcd**: `npm install etcd3`
|
|
47
|
+
- **GraphQL root field key generation**: `npm install graphql`
|
|
29
48
|
|
|
30
49
|
---
|
|
31
50
|
|
|
32
51
|
## Quick Start
|
|
33
52
|
|
|
53
|
+
### Express middleware
|
|
54
|
+
|
|
34
55
|
```javascript
|
|
35
56
|
import express from 'express';
|
|
36
57
|
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
@@ -53,13 +74,42 @@ app.get('/', (req, res) => {
|
|
|
53
74
|
app.listen(3000);
|
|
54
75
|
```
|
|
55
76
|
|
|
77
|
+
### Framework-agnostic core limiter
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
import { MemoryLimiter } from 'express-adaptive-rate-limiter';
|
|
81
|
+
|
|
82
|
+
const limiter = new MemoryLimiter({ points: 100, duration: 60 });
|
|
83
|
+
|
|
84
|
+
const res = await limiter.consume('user-42');
|
|
85
|
+
console.log(res.remainingPoints, res.msBeforeNext);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Core limiter behind Express
|
|
89
|
+
|
|
90
|
+
Any core limiter can be turned into Express middleware:
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
import express from 'express';
|
|
94
|
+
import { MongoLimiter, createCoreMiddleware } from 'express-adaptive-rate-limiter';
|
|
95
|
+
|
|
96
|
+
const app = express();
|
|
97
|
+
|
|
98
|
+
app.use(
|
|
99
|
+
createCoreMiddleware(
|
|
100
|
+
new MongoLimiter({ points: 100, duration: 60, dbName: 'my-app' }),
|
|
101
|
+
{ standardHeaders: true }
|
|
102
|
+
)
|
|
103
|
+
);
|
|
104
|
+
```
|
|
105
|
+
|
|
56
106
|
---
|
|
57
107
|
|
|
58
108
|
## Supported Algorithms
|
|
59
109
|
|
|
60
110
|
| Algorithm | Identifier | Best For | Memory | Redis | Atomic |
|
|
61
111
|
| --------------------------- | ------------------------ | -------------------------------------------- | :----: | :------: | :----: |
|
|
62
|
-
| **Fixed Window Counter** | `fixed-window` | Low-overhead,
|
|
112
|
+
| **Fixed Window Counter** | `fixed-window` | Low-overhead, window anchored on first hit | ✅ | ✅ (Lua) | ✅ |
|
|
63
113
|
| **Sliding Window Log** | `sliding-window-log` | 100% strict precision rolling window | ✅ | ✅ (Lua) | ✅ |
|
|
64
114
|
| **Sliding Window Counter** | `sliding-window-counter` | Smooth rolling limits with O(1) memory | ✅ | ✅ (Lua) | ✅ |
|
|
65
115
|
| **Token Bucket** | `token-bucket` | Bursts allowed with steady background refill | ✅ | ✅ (Lua) | ✅ |
|
|
@@ -68,6 +118,157 @@ app.listen(3000);
|
|
|
68
118
|
|
|
69
119
|
---
|
|
70
120
|
|
|
121
|
+
## Core Tier: Framework-Agnostic Limiters
|
|
122
|
+
|
|
123
|
+
The core tier is a high-performance points-based limiter. A limiter holds a counter per key; the counter is reset when its window expires.
|
|
124
|
+
|
|
125
|
+
### The API
|
|
126
|
+
|
|
127
|
+
Every core limiter exposes the same surface:
|
|
128
|
+
|
|
129
|
+
| Method | Description |
|
|
130
|
+
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
131
|
+
| `consume(key, points?, options?)` | Consume points. Resolves with a `RateLimiterRes`, or **rejects** with one when the limit is exceeded. |
|
|
132
|
+
| `penalty(key, points?, options?)` | Increase the consumed points without moving the window. |
|
|
133
|
+
| `reward(key, points?, options?)` | Decrease the consumed points without moving the window. |
|
|
134
|
+
| `get(key, options?)` | Read the current state, or `null` when the key is unknown. |
|
|
135
|
+
| `set(key, points, secDuration?, options?)` | Overwrite the counter and restart the window. |
|
|
136
|
+
| `block(key, secDuration, options?)` | Block a key for a number of seconds. |
|
|
137
|
+
| `delete(key, options?)` | Reset a key in the store and clear in-memory blocks. Returns whether it existed. |
|
|
138
|
+
| `deleteInMemoryBlocked(key)` | Remove only the local in-process block for a specific key. |
|
|
139
|
+
| `deleteInMemoryBlockedAll()` | Remove all active in-process blocks. |
|
|
140
|
+
| `getKey(key)` / `parseKey(rlKey)` | Prefix handling for the storage key. |
|
|
141
|
+
|
|
142
|
+
`RateLimiterRes` carries the following fields:
|
|
143
|
+
|
|
144
|
+
```javascript
|
|
145
|
+
{
|
|
146
|
+
remainingPoints: 99, // points still available
|
|
147
|
+
msBeforeNext: 59832, // ms until the window resets (-1 = never)
|
|
148
|
+
consumedPoints: 1, // points consumed in this window
|
|
149
|
+
isFirstInDuration: true // whether this call opened the window
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Common options
|
|
154
|
+
|
|
155
|
+
| Option | Type | Default | Description |
|
|
156
|
+
| ------------------------ | ----------------- | ------- | ------------------------------------------------------------------------- |
|
|
157
|
+
| `points` | `number` | `60` | Maximum points consumable within the window. `limit` is an alias. |
|
|
158
|
+
| `duration` | `number` | `60` | Window length in **seconds**. `0` means points are never reset. |
|
|
159
|
+
| `windowMs` | `number` | — | Alias of `duration`, in milliseconds. |
|
|
160
|
+
| `blockDuration` | `number` | `0` | Seconds a key stays blocked once it exceeds its limit. |
|
|
161
|
+
| `execEvenly` | `boolean` | `false` | Spread allowed requests evenly across the window. |
|
|
162
|
+
| `execEvenlyMinDelayMs` | `number` | derived | Minimum delay between two evenly spread executions. |
|
|
163
|
+
| `keyPrefix` | `string` | `'rl:'` | Prefix applied to every stored key. |
|
|
164
|
+
| `insuranceLimiter` | `RateLimiter` | `null` | Fallback limiter used when the primary store fails. |
|
|
165
|
+
| `inMemoryBlockOnConsumed`| `number` | `0` | Block a key in process memory once this many points are consumed. |
|
|
166
|
+
| `inMemoryBlockDuration` | `number` | `0` | Seconds the in-memory block lasts. |
|
|
167
|
+
| `clearExpiredByTimeout` | `boolean` | `false` | Automatically purge expired rows from SQL/Mongo tables periodically. |
|
|
168
|
+
| `clearExpiredTimeoutMs` | `number` | `300000`| Interval for expired key purge timer (default 5 minutes). |
|
|
169
|
+
|
|
170
|
+
### Backends & Class Aliases
|
|
171
|
+
|
|
172
|
+
Both modern descriptive names and classic class aliases are exported for convenience:
|
|
173
|
+
|
|
174
|
+
| Descriptive Limiter | Class Alias | Driver | Atomic mechanism |
|
|
175
|
+
| ------------------- | ----------------------------- | ----------------------------------------- | ------------------------------------ |
|
|
176
|
+
| `MemoryLimiter` | `RateLimiterMemory` | none | in-process Map with O(1) eviction |
|
|
177
|
+
| `RedisLimiter` | `RateLimiterRedis` | `ioredis` or `redis` | Lua script |
|
|
178
|
+
| `RateLimiterCluster`| `RateLimiterCluster` | `node:cluster` IPC | Master-Worker IPC messaging |
|
|
179
|
+
| `RedisClusterLimiter`| `ClusterLimiter` | ioredis Cluster / node-redis cluster | Lua script, hash-tagged `{slot}` |
|
|
180
|
+
| `ValkeyLimiter` | `RateLimiterValkey` | `iovalkey` or `@valkey/valkey-glide` | Lua script |
|
|
181
|
+
| `MongoLimiter` | `RateLimiterMongo` | `mongodb` | `findOneAndUpdate` with `$inc` |
|
|
182
|
+
| `MongooseLimiter` | `RateLimiterMongoose` | `mongoose` | `findOneAndUpdate` with `$inc` |
|
|
183
|
+
| `DynamoDbLimiter` | `RateLimiterDynamo` / `DynamoDb` | `@aws-sdk/client-dynamodb` | `UpdateItem` with `ADD` |
|
|
184
|
+
| `MySqlLimiter` | `RateLimiterMySQL` | `mysql2` | `INSERT ... ON DUPLICATE KEY UPDATE` |
|
|
185
|
+
| `PostgresLimiter` | `RateLimiterPostgres` | `pg` | `INSERT ... ON CONFLICT DO UPDATE` |
|
|
186
|
+
| `SqliteLimiter` | `RateLimiterSQLite` | `better-sqlite3` or built-in `node:sqlite`| `INSERT ... ON CONFLICT DO UPDATE` |
|
|
187
|
+
| `PrismaLimiter` | `RateLimiterPrisma` | `@prisma/client` | raw upsert, Postgres or MySQL |
|
|
188
|
+
| `MemcachedLimiter` | `RateLimiterMemcache` / `Memcached` | `memjs` / `memcached` | `add` for creation, `incr`/`decr` |
|
|
189
|
+
| `EtcdLimiter` | `RateLimiterEtcd` | `etcd3` | compare-and-swap transaction |
|
|
190
|
+
|
|
191
|
+
Each backend accepts its driver through `storeClient` (with `client` as an alias).
|
|
192
|
+
|
|
193
|
+
### Node.js Cluster Rate Limiting (`RateLimiterCluster` & `RateLimiterClusterMaster`)
|
|
194
|
+
|
|
195
|
+
Rate-limit across multiple CPU cores without Redis using Node's native Inter-Process Communication (IPC):
|
|
196
|
+
|
|
197
|
+
```javascript
|
|
198
|
+
import cluster from 'node:cluster';
|
|
199
|
+
import { RateLimiterClusterMaster, RateLimiterCluster } from 'express-adaptive-rate-limiter';
|
|
200
|
+
|
|
201
|
+
if (cluster.isPrimary || cluster.isMaster) {
|
|
202
|
+
// Master process initializes the central coordinator
|
|
203
|
+
new RateLimiterClusterMaster();
|
|
204
|
+
|
|
205
|
+
// Fork worker processes
|
|
206
|
+
for (let i = 0; i < 4; i++) {
|
|
207
|
+
cluster.fork();
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
// Worker process rate limiter
|
|
211
|
+
const limiter = new RateLimiterCluster({
|
|
212
|
+
keyPrefix: 'cluster_rl',
|
|
213
|
+
points: 10,
|
|
214
|
+
duration: 1,
|
|
215
|
+
timeoutMs: 3000 // failsafe timeout
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const res = await limiter.consume('user-ip');
|
|
219
|
+
console.log(res.remainingPoints);
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### In-Memory State Dump & Restore (`dump()` & `restore()`)
|
|
224
|
+
|
|
225
|
+
Save in-memory state on shutdown for zero-downtime restarts:
|
|
226
|
+
|
|
227
|
+
```javascript
|
|
228
|
+
import fs from 'node:fs';
|
|
229
|
+
import { RateLimiterMemory } from 'express-adaptive-rate-limiter';
|
|
230
|
+
|
|
231
|
+
const limiter = new RateLimiterMemory({ points: 10, duration: 60 });
|
|
232
|
+
|
|
233
|
+
// On graceful shutdown
|
|
234
|
+
const snapshot = limiter.dump();
|
|
235
|
+
fs.writeFileSync('./rate-limit-state.json', JSON.stringify(snapshot));
|
|
236
|
+
|
|
237
|
+
// On application startup
|
|
238
|
+
if (fs.existsSync('./rate-limit-state.json')) {
|
|
239
|
+
const data = JSON.parse(fs.readFileSync('./rate-limit-state.json', 'utf8'));
|
|
240
|
+
const report = limiter.restore(data);
|
|
241
|
+
console.log(`Restored ${report.restored} keys, ${report.expired} expired`);
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Wrappers
|
|
246
|
+
|
|
247
|
+
| Wrapper | Purpose |
|
|
248
|
+
| ---------------------------------------- | ---------------------------------------------------------------- |
|
|
249
|
+
| `RateLimiterQueue` | Queues requests instead of rejecting them when the limit is full.|
|
|
250
|
+
| `RateLimiterUnion` | Requires every wrapped limiter to allow the request. |
|
|
251
|
+
| `BurstyRateLimiter` | A steady limiter with a burst fallback. |
|
|
252
|
+
| `BlackAndWhiteLimiter` / `RateLimiterBlackAndWhite` | Black and white lists in front of a limiter. |
|
|
253
|
+
| `TimeoutLimiter` | Fails a call that takes longer than `timeoutMs`. |
|
|
254
|
+
|
|
255
|
+
```javascript
|
|
256
|
+
import { MemoryLimiter, RateLimiterUnion, BlackAndWhiteLimiter } from 'express-adaptive-rate-limiter';
|
|
257
|
+
|
|
258
|
+
const perIp = new MemoryLimiter({ points: 100, duration: 60 });
|
|
259
|
+
const perAccount = new MemoryLimiter({ points: 10, duration: 60, keyPrefix: 'acct' });
|
|
260
|
+
|
|
261
|
+
const both = new RateLimiterUnion(perIp, perAccount);
|
|
262
|
+
|
|
263
|
+
const guarded = new BlackAndWhiteLimiter({
|
|
264
|
+
limiter: both,
|
|
265
|
+
whiteList: ['10.0.0.0/8'],
|
|
266
|
+
blackList: ['203.0.113.5']
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
71
272
|
## Configuration Reference
|
|
72
273
|
|
|
73
274
|
### 1. Common Options
|
|
@@ -107,7 +308,7 @@ All options below can be passed to `rateLimit(options)`:
|
|
|
107
308
|
|
|
108
309
|
#### Fixed Window (`algorithm: 'fixed-window'`)
|
|
109
310
|
|
|
110
|
-
|
|
311
|
+
Counts requests in a window that starts with the **first request of a key** and lasts `windowMs`. The window never slides, so a client can never consume two windows worth of quota in quick succession.
|
|
111
312
|
|
|
112
313
|
| Option | Type | Default | Description |
|
|
113
314
|
| --------------- | -------- | ------- | ------------------------------------------- |
|
|
@@ -229,6 +430,8 @@ rateLimit({
|
|
|
229
430
|
|
|
230
431
|
### 4. Storage Backends
|
|
231
432
|
|
|
433
|
+
The Express tier uses the algorithm stores (`MemoryStore`, `RedisStore`) described below. The **core tier** ships its own set of backends — see [Core Tier](#core-tier-framework-agnostic-limiters) for the full list and their options.
|
|
434
|
+
|
|
232
435
|
#### MemoryStore Options
|
|
233
436
|
|
|
234
437
|
```javascript
|
|
@@ -604,6 +807,8 @@ Retry-After: 45
|
|
|
604
807
|
| `validateIpRangeV4` | `(ip, whitelist?) => boolean` | Tests IP against whitelist or `Platform.WHITELIST_IPS`. |
|
|
605
808
|
| `validateSecondaryIps` | `(req, whitelist?) => boolean` | Validates `x-real-ip` or `x-forwarded-for` when `x-xcld: true`. |
|
|
606
809
|
| `evaluateSkipRule` | `(rule, req, res, context?) => Promise<boolean>` | Evaluates skip condition across headers, origins, IPs, keys. |
|
|
810
|
+
| `createCoreMiddleware` | `(limiter, options?) => middleware` | Turns a core limiter into Express middleware. |
|
|
811
|
+
| `detectRedisClientKind` | `(client) => string` | Reports which Redis client family is in use. |
|
|
607
812
|
|
|
608
813
|
---
|
|
609
814
|
|
|
@@ -616,6 +821,7 @@ All error classes inherit from `RateLimiterError`:
|
|
|
616
821
|
- `RedisConnectionError`: Thrown when Redis connection, command, or script execution fails.
|
|
617
822
|
- `QueueFullError`: Thrown in Leaky Bucket shaping mode when `maxQueueSize` is exceeded.
|
|
618
823
|
- `QueueTimeoutError`: Thrown in Leaky Bucket shaping mode when a queued request times out (`queueTimeoutMs`).
|
|
824
|
+
- `RateLimiterQueueError`: Thrown by `RateLimiterQueue` when the queue is full or a queued request expires.
|
|
619
825
|
|
|
620
826
|
---
|
|
621
827
|
|
|
@@ -651,6 +857,34 @@ app.get('/info', (req, res) => {
|
|
|
651
857
|
});
|
|
652
858
|
```
|
|
653
859
|
|
|
860
|
+
## Performance Benchmarks
|
|
861
|
+
|
|
862
|
+
Run the benchmark suite with `npm run bench`:
|
|
863
|
+
|
|
864
|
+
```
|
|
865
|
+
===============================================================================================
|
|
866
|
+
Express Adaptive Rate Limiter - Performance Benchmark Suite
|
|
867
|
+
===============================================================================================
|
|
868
|
+
| Algorithm & Store | Reqs | Throughput | Mean | p50 | p95 | p99 |
|
|
869
|
+
-----------------------------------------------------------------------------------------------
|
|
870
|
+
| Fixed Window (Memory) | 10000 | 254,535 req/s | 0.078 ms | 0.049 ms | 0.172 ms | 0.654 ms |
|
|
871
|
+
| Sliding Window Counter (Memory) | 10000 | 264,659 req/s | 0.075 ms | 0.060 ms | 0.116 ms | 0.786 ms |
|
|
872
|
+
| Token Bucket (Memory) | 10000 | 321,866 req/s | 0.061 ms | 0.047 ms | 0.087 ms | 0.555 ms |
|
|
873
|
+
| Leaky Bucket Policing (Memory) | 10000 | 188,326 req/s | 0.105 ms | 0.079 ms | 0.246 ms | 0.749 ms |
|
|
874
|
+
| Sliding Window Log (Memory) | 10000 | 121,287 req/s | 0.164 ms | 0.127 ms | 0.227 ms | 0.826 ms |
|
|
875
|
+
| Core Points Limiter (Memory) | 10000 | 205,303 req/s | 0.095 ms | 0.070 ms | 0.190 ms | 0.660 ms |
|
|
876
|
+
| Core Points Limiter (Redis) | 1000 | 1,200+ req/s| 0.810 ms | 0.720 ms | 1.450 ms | 2.100 ms |
|
|
877
|
+
===============================================================================================
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
Key performance highlights:
|
|
881
|
+
- **Zero-overhead memory tier**: 250,000–320,000 requests/sec with p50 latency under 0.06 ms.
|
|
882
|
+
- **Atomic Redis scripts**: Single round-trip Lua scripts for atomic increments and TTL sets without race conditions.
|
|
883
|
+
- **In-memory blocking shield**: Protects Redis or SQL databases against high-frequency brute-force/DoS attacks by caching blocks in local process memory (`inMemoryBlockOnConsumed`).
|
|
884
|
+
- **O(1) LRU eviction**: High memory ceilings without garbage collection pauses.
|
|
885
|
+
|
|
886
|
+
---
|
|
887
|
+
|
|
654
888
|
## License
|
|
655
889
|
|
|
656
890
|
MIT © Express Adaptive Rate Limiter Team
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
export class BaseAlgorithm{constructor(options,store){this.options=options;this.store=store;this.locks=new Map();}isRedis(){return this.store&&typeof this.store.runScript==='function';}normalizeResult(allowed,limit,remaining,consumed,retryAfter,resetTime){const resetMs=Number(resetTime);const resetDate=new Date(resetMs);return{allowed:Boolean(allowed),limit:Number(limit),remaining:Math.max(0,Number(remaining)),consumed:Math.max(0,Number(consumed)),retryAfter:Math.max(0,Number(retryAfter)),resetTime:resetDate};}async withLock(key,fn){const prev=this.locks.get(key)||Promise.resolve();let release;const current=new Promise(resolve=>{release=resolve;});this.locks.set(key,current);try{await prev;return await fn();}finally{release();if(this.locks.get(key)===current){this.locks.delete(key);}}}async consume(_key,_cost=1){throw new Error('Algorithm.consume() must be implemented');}async reset(_key){return this.store.delete(_key);}async getState(_key){throw new Error('Algorithm.getState() must be implemented');}async close(){this.locks.clear();if(this.store&&typeof this.store.close==='function'){await this.store.close();}}}export default BaseAlgorithm;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class FixedWindow extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}resolveWindow(state,now){if(state&&typeof state.resetTime==='number'&&state.resetTime>now){return{count:state.count||0,windowStart:state.windowStart,resetTime:state.resetTime};}return{count:0,windowStart:now,resetTime:now+this.windowMs};}async consume(key,cost=1){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('fixed-window',[key],[this.limit,this.windowMs,cost,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const state=await this.store.get(key);const window=this.resolveWindow(state,now);const ttl=Math.max(1,window.resetTime-now);if(window.count+cost<=this.limit){const nextCount=window.count+cost;await this.store.set(key,{count:nextCount,windowStart:window.windowStart,resetTime:window.resetTime},ttl);return this.normalizeResult(true,this.limit,this.limit-nextCount,nextCount,0,window.resetTime);}await this.store.set(key,{count:window.count,windowStart:window.windowStart,resetTime:window.resetTime},ttl);const retryAfter=Math.max(1,Math.ceil(ttl/1000));const remaining=Math.max(0,this.limit-window.count);return this.normalizeResult(false,this.limit,remaining,window.count,retryAfter,window.resetTime);});}async getState(key){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('fixed-window',[key],[this.limit,this.windowMs,0,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const state=await this.store.get(key);const window=this.resolveWindow(state,now);const remaining=Math.max(0,this.limit-window.count);const retryAfter=window.count>=this.limit?Math.max(1,Math.ceil((window.resetTime-now)/1000)):0;return this.normalizeResult(window.count<this.limit,this.limit,remaining,window.count,retryAfter,window.resetTime);}}export default FixedWindow;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';import{QueueFullError,QueueTimeoutError}from'../utils/errors.js';export class LeakyBucket extends BaseAlgorithm{constructor(options,store){super(options,store);this.capacity=options.capacity;this.leakRate=options.leakRate;this.leakInterval=options.leakInterval;this.mode=options.mode||'policing';this.maxQueueSize=options.maxQueueSize||100;this.queueTimeoutMs=options.queueTimeoutMs||30000;this.rate=this.leakRate/this.leakInterval;this.queues=new Map();}async _checkPolicing(key,cost){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('leaky-bucket',[key],[this.capacity,this.leakRate,this.leakInterval,cost,nowArg]);const normalized=this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);return{...normalized,waitMs:Number(res[6]||0)};}return this.withLock(key,async()=>{const now=Date.now();const state=await this.store.get(key);let water=0;let lastLeak=now;if(state&&typeof state.water==='number'){const elapsed=Math.max(0,now-state.lastLeak);water=Math.max(0,state.water-elapsed*this.rate);lastLeak=now;}const ttlMs=Math.max(1000,Math.ceil(this.capacity/this.rate*2.0));if(water+cost<=this.capacity){water+=cost;const consumed=Math.ceil(water);const remaining=Math.max(0,Math.floor(this.capacity-water));const resetTime=now+Math.ceil(water/this.rate);await this.store.set(key,{water,lastLeak},ttlMs);return{...this.normalizeResult(true,this.capacity,remaining,consumed,0,resetTime),waitMs:0};}const consumed=Math.ceil(water);const remaining=Math.max(0,Math.floor(this.capacity-water));const deficit=water+cost-this.capacity;const waitMs=Math.ceil(deficit/this.rate);const retryAfter=Math.max(1,Math.ceil(waitMs/1000));const resetTime=now+Math.ceil(water/this.rate);await this.store.set(key,{water,lastLeak},ttlMs);return{...this.normalizeResult(false,this.capacity,remaining,consumed,retryAfter,resetTime),waitMs};});}async consume(key,cost=1,extra={}){const policingResult=await this._checkPolicing(key,cost);if(this.mode==='policing'||policingResult.allowed){return policingResult;}return this._enqueue(key,cost,policingResult,extra);}_enqueue(key,cost,immediateResult,extra={}){if(extra.req&&(extra.req.destroyed||extra.req.closed||extra.req.aborted)){return{...immediateResult,allowed:false,aborted:true};}let queue=this.queues.get(key);if(!queue){queue=[];this.queues.set(key,queue);}if(queue.length>=this.maxQueueSize){return{...immediateResult,allowed:false,error:new QueueFullError()};}const waitMs=immediateResult.waitMs;if(waitMs>this.queueTimeoutMs){return{...immediateResult,allowed:false,retryAfter:Math.max(1,Math.ceil(waitMs/1000)),error:new QueueTimeoutError()};}return new Promise(resolve=>{let timer=null;let cleanedUp=false;const item={cost,resolve,timer:null};const cleanup=()=>{if(cleanedUp)return;cleanedUp=true;if(timer){clearTimeout(timer);timer=null;}const currentQueue=this.queues.get(key);if(currentQueue){const idx=currentQueue.indexOf(item);if(idx!==-1){currentQueue.splice(idx,1);}if(currentQueue.length===0){this.queues.delete(key);}}};if(extra.req&&typeof extra.req.on==='function'){extra.req.once('close',()=>{if(!cleanedUp){cleanup();resolve({...immediateResult,allowed:false,aborted:true});}});}timer=setTimeout(async()=>{cleanup();try{const afterWaitResult=await this._checkPolicing(key,cost);resolve(afterWaitResult);}catch{resolve({...immediateResult,allowed:false});}},waitMs);if(timer.unref){timer.unref();}item.timer=timer;queue.push(item);});}async getState(key){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('leaky-bucket',[key],[this.capacity,this.leakRate,this.leakInterval,0,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const state=await this.store.get(key);let water=0;if(state&&typeof state.water==='number'){const elapsed=Math.max(0,now-state.lastLeak);water=Math.max(0,state.water-elapsed*this.rate);}const consumed=Math.ceil(water);const remaining=Math.max(0,Math.floor(this.capacity-water));const resetTime=now+Math.ceil(water/this.rate);const retryAfter=water>=this.capacity?Math.max(1,Math.ceil(1/this.rate/1000)):0;return this.normalizeResult(water<this.capacity,this.capacity,remaining,consumed,retryAfter,resetTime);}async close(){for(const[,queue]of this.queues){for(const item of queue){if(item.timer){clearTimeout(item.timer);}item.resolve({allowed:false,limit:this.capacity,remaining:0,consumed:this.capacity,retryAfter:1,resetTime:new Date()});}}this.queues.clear();await super.close();}}export default LeakyBucket;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class SlidingWindowCounter extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}async consume(key,cost=1){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-counter',[key],[this.limit,this.windowMs,cost,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const currWindowStart=Math.floor(now/this.windowMs)*this.windowMs;const resetTime=currWindowStart+this.windowMs;const state=await this.store.get(key);let prevCount=0;let currCount=0;if(state){if(state.windowStart===currWindowStart){prevCount=state.prevCount||0;currCount=state.currCount||0;}else if(currWindowStart===state.windowStart+this.windowMs){prevCount=state.currCount||0;currCount=0;}}const elapsed=now-currWindowStart;const weight=Math.max(0,1-elapsed/this.windowMs);const estimated=prevCount*weight+currCount;if(estimated+cost<=this.limit){currCount+=cost;const consumed=Math.round(estimated+cost);const remaining=Math.max(0,this.limit-consumed);await this.store.set(key,{windowStart:currWindowStart,prevCount,currCount},this.windowMs*2);return this.normalizeResult(true,this.limit,remaining,consumed,0,resetTime);}const consumed=Math.round(estimated);const remaining=Math.max(0,this.limit-consumed);let waitMs=resetTime-now;if(prevCount>0){const neededWeight=(this.limit-currCount-cost)/prevCount;if(neededWeight<1&&neededWeight>=0){const t=this.windowMs*(1-neededWeight);waitMs=Math.max(1,Math.ceil(t-elapsed));}}if(waitMs<=0||waitMs>this.windowMs){waitMs=Math.max(1,resetTime-now);}const retryAfter=Math.max(1,Math.ceil(waitMs/1000));await this.store.set(key,{windowStart:currWindowStart,prevCount,currCount},this.windowMs*2);return this.normalizeResult(false,this.limit,remaining,consumed,retryAfter,resetTime);});}async getState(key){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-counter',[key],[this.limit,this.windowMs,0,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const currWindowStart=Math.floor(now/this.windowMs)*this.windowMs;const resetTime=currWindowStart+this.windowMs;const state=await this.store.get(key);let prevCount=0;let currCount=0;if(state){if(state.windowStart===currWindowStart){prevCount=state.prevCount||0;currCount=state.currCount||0;}else if(currWindowStart===state.windowStart+this.windowMs){prevCount=state.currCount||0;currCount=0;}}const elapsed=now-currWindowStart;const weight=Math.max(0,1-elapsed/this.windowMs);const estimated=prevCount*weight+currCount;const consumed=Math.round(estimated);const remaining=Math.max(0,this.limit-consumed);const retryAfter=estimated>=this.limit?Math.max(1,Math.ceil((resetTime-now)/1000)):0;return this.normalizeResult(estimated<this.limit,this.limit,remaining,consumed,retryAfter,resetTime);}}export default SlidingWindowCounter;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class SlidingWindowLog extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}async consume(key,cost=1){if(this.isRedis()){const uid=`${Date.now()}:${Math.random().toString(36).slice(2,8)}`;const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-log',[key],[this.limit,this.windowMs,cost,uid,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const windowStart=now-this.windowMs;const state=await this.store.get(key);let log=[];if(state&&Array.isArray(state.log)){log=state.log.filter(entry=>entry.time>windowStart);}let current=0;for(let i=0;i<log.length;i++){current+=log[i].cost;}if(current+cost<=this.limit){log.push({time:now,cost});const consumed=current+cost;const remaining=this.limit-consumed;const oldestTime=log[0].time;const resetTime=oldestTime+this.windowMs;await this.store.set(key,{log},this.windowMs);return this.normalizeResult(true,this.limit,remaining,consumed,0,resetTime);}const deficit=current+cost-this.limit;let accumulated=0;let expireTime=now;for(let i=0;i<log.length;i++){accumulated+=log[i].cost;if(accumulated>=deficit){expireTime=log[i].time;break;}}const resetTime=expireTime+this.windowMs;const waitMs=Math.max(1,resetTime-now);const retryAfter=Math.max(1,Math.ceil(waitMs/1000));const remaining=Math.max(0,this.limit-current);await this.store.set(key,{log},this.windowMs);return this.normalizeResult(false,this.limit,remaining,current,retryAfter,resetTime);});}async getState(key){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-log',[key],[this.limit,this.windowMs,0,'inspect',nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const windowStart=now-this.windowMs;const state=await this.store.get(key);let log=[];if(state&&Array.isArray(state.log)){log=state.log.filter(entry=>entry.time>windowStart);}let current=0;for(let i=0;i<log.length;i++){current+=log[i].cost;}const remaining=Math.max(0,this.limit-current);const oldestTime=log.length>0?log[0].time:now;const resetTime=oldestTime+this.windowMs;const retryAfter=current>=this.limit?Math.max(1,Math.ceil((resetTime-now)/1000)):0;return this.normalizeResult(current<this.limit,this.limit,remaining,current,retryAfter,resetTime);}}export default SlidingWindowLog;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class TokenBucket extends BaseAlgorithm{constructor(options,store){super(options,store);this.capacity=options.capacity;this.refillRate=options.refillRate;this.refillInterval=options.refillInterval;this.initialTokens=options.initialTokens!==undefined?options.initialTokens:this.capacity;this.rate=this.refillRate/this.refillInterval;}async consume(key,cost=1){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('token-bucket',[key],[this.capacity,this.refillRate,this.refillInterval,cost,this.initialTokens,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const state=await this.store.get(key);let tokens=this.initialTokens;let lastRefill=now;if(state&&typeof state.tokens==='number'){const elapsed=Math.max(0,now-state.lastRefill);tokens=Math.min(this.capacity,state.tokens+elapsed*this.rate);lastRefill=now;}const ttlMs=Math.max(1000,Math.ceil(this.capacity/this.rate*2.0));if(tokens>=cost){tokens-=cost;const consumed=this.capacity-Math.floor(tokens);const remaining=Math.floor(tokens);const resetTime=now+Math.ceil((this.capacity-tokens)/this.rate);await this.store.set(key,{tokens,lastRefill},ttlMs);return this.normalizeResult(true,this.capacity,remaining,consumed,0,resetTime);}const consumed=this.capacity-Math.floor(tokens);const remaining=Math.floor(tokens);const deficit=cost-tokens;const waitMs=Math.ceil(deficit/this.rate);const retryAfter=Math.max(1,Math.ceil(waitMs/1000));const resetTime=now+Math.ceil((this.capacity-tokens)/this.rate);await this.store.set(key,{tokens,lastRefill},ttlMs);return this.normalizeResult(false,this.capacity,remaining,consumed,retryAfter,resetTime);});}async getState(key){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('token-bucket',[key],[this.capacity,this.refillRate,this.refillInterval,0,this.initialTokens,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const state=await this.store.get(key);let tokens=this.initialTokens;if(state&&typeof state.tokens==='number'){const elapsed=Math.max(0,now-state.lastRefill);tokens=Math.min(this.capacity,state.tokens+elapsed*this.rate);}const consumed=this.capacity-Math.floor(tokens);const remaining=Math.floor(tokens);const resetTime=now+Math.ceil((this.capacity-tokens)/this.rate);const retryAfter=tokens<1?Math.max(1,Math.ceil((1-tokens)/this.rate/1000)):0;return this.normalizeResult(tokens>=1,this.capacity,remaining,consumed,retryAfter,resetTime);}}export default TokenBucket;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class BlockedKeys{constructor(){this._keys=new Map();}add(key,durationSec){const durationMs=Math.max(0,Number(durationSec)||0)*1000;this.addMs(key,durationMs);}_sweep(now=Date.now()){for(const[key,expiresAt]of this._keys.entries()){if(expiresAt<=now){this._keys.delete(key);}}}addMs(key,durationMs){const now=Date.now();if(this._keys.size>999){this._sweep(now);}const ms=Math.max(0,Number(durationMs)||0);this._keys.set(key,now+ms);}msBeforeExpire(key){const expiresAt=this._keys.get(key);if(expiresAt===undefined)return 0;const remaining=expiresAt-Date.now();if(remaining<=0){this._keys.delete(key);return 0;}return remaining;}isBlocked(key){return this.msBeforeExpire(key)>0;}delete(key){return this._keys.delete(key);}deleteAll(){this._keys.clear();}get size(){return this._keys.size;}}export default BlockedKeys;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{defaultKeyGenerator,ipKeyGenerator,graphQLKeyGenerator}from'../utils/keyGenerator.js';import{evaluateSkipRule}from'../utils/skipUtils.js';import{RateLimiterRes}from'../core/rateLimiterRes.js';export function createCoreMiddleware(limiter,options={}){if(!limiter||typeof limiter.consume!=='function'){throw new TypeError('createCoreMiddleware requires a limiter with a consume() method');}let keyGen=defaultKeyGenerator;if(typeof options.keyGenerator==='function'){keyGen=options.keyGenerator;}else if(options.keyGenerator==='ip'){keyGen=ipKeyGenerator;}else if(options.keyGenerator==='graphql'){keyGen=graphQLKeyGenerator;}const rawSkip=options.skip!==undefined?options.skip:options.customSkip!==undefined?options.customSkip:options.customskip;const skipFn=typeof rawSkip==='function'?rawSkip:typeof rawSkip==='object'&&rawSkip!==null?(req,res)=>evaluateSkipRule(rawSkip,req,res,{generateKey:keyGen}):()=>false;const defaultHandler=(req,res,next,opts)=>{res.status(opts.statusCode);const msg=typeof opts.message==='function'?opts.message(req,res):opts.message;if(typeof msg==='object'){res.json(msg);}else{res.send(msg);}};const handler=options.handler||defaultHandler;const resolvedOptions={standardHeaders:options.standardHeaders!==undefined?options.standardHeaders:true,legacyHeaders:options.legacyHeaders!==undefined?options.legacyHeaders:false,...options,statusCode:options.statusCode!==undefined?options.statusCode:429,message:options.message!==undefined?options.message:'Too many requests, please try again later.'};const middleware=async(req,res,next)=>{try{const shouldSkip=await Promise.resolve(skipFn(req,res));if(shouldSkip){return next();}const key=await Promise.resolve(keyGen(req,res));let cost=1;if(typeof options.cost==='function'){cost=await Promise.resolve(options.cost(req,res));}else if(typeof options.cost==='number'){cost=options.cost;}if(!Number.isFinite(cost)||cost<=0){cost=1;}let res1;try{res1=await limiter.consume(key,cost,{req,res});}catch(err){if(err instanceof RateLimiterRes){res1=err;}else if(err&&typeof err==='object'&&Object.values(err).some(v=>v instanceof RateLimiterRes)){const resValues=Object.values(err).filter(v=>v instanceof RateLimiterRes);const maxMsBeforeNext=Math.max(...resValues.map(v=>v.msBeforeNext||0));const minRemaining=Math.min(...resValues.map(v=>v.remainingPoints||0));const maxConsumed=Math.max(...resValues.map(v=>v.consumedPoints||0));res1=new RateLimiterRes(minRemaining,maxMsBeforeNext,maxConsumed,false);res1._unionRejections=err;}else{throw err;}}if(res1&&!(res1 instanceof RateLimiterRes)&&typeof res1==='object'&&Object.values(res1).every(v=>v instanceof RateLimiterRes)){const resValues=Object.values(res1);const maxMsBeforeNext=Math.max(...resValues.map(v=>v.msBeforeNext||0));const minRemaining=Math.min(...resValues.map(v=>v.remainingPoints||0));const maxConsumed=Math.max(...resValues.map(v=>v.consumedPoints||0));res1=new RateLimiterRes(minRemaining,maxMsBeforeNext,maxConsumed,resValues.some(v=>v.isFirstInDuration));}const result=toMiddlewareResult(res1,limiter,cost);req.rateLimit=result;setCoreHeaders(res,result,{...resolvedOptions,limiter});if(result.allowed){return next();}return handler(req,res,next,resolvedOptions);}catch(err){if(options.failOpen){req.rateLimit={allowed:true,limit:limiter.points||60,remaining:1,consumed:0,retryAfter:0,resetTime:new Date(Date.now()+(limiter.msDuration||60000)),failedOpen:true};if(typeof options.onError==='function'){options.onError(err,req,res,next);}return next();}if(typeof options.onError==='function'){return options.onError(err,req,res,next);}return next(err);}};middleware.resetKey=async keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;if(typeof limiter.delete==='function'){return limiter.delete(k);}return false;};middleware.getState=async keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;if(typeof limiter.get==='function'){return limiter.get(k);}return null;};middleware.limiter=limiter;return middleware;}function toMiddlewareResult(res,limiter,cost){const limit=typeof limiter.points==='number'?limiter.points:typeof res.consumedPoints==='number'&&typeof res.remainingPoints==='number'?res.consumedPoints+res.remainingPoints:60;const consumed=typeof res.consumedPoints==='number'?res.consumedPoints:cost;const msBeforeNext=typeof res.msBeforeNext==='number'?res.msBeforeNext:0;const rawReset=Number.isFinite(msBeforeNext)&&msBeforeNext>0?Date.now()+Math.min(msBeforeNext,31536000000):Date.now();const resetTime=new Date(rawReset);return{allowed:consumed<=limit,limit,remaining:Math.max(0,limit-consumed),consumed,retryAfter:msBeforeNext>0?Math.max(1,Math.ceil(msBeforeNext/1000)):0,resetTime:Number.isNaN(resetTime.getTime())?new Date(Date.now()+3600000):resetTime,cost};}function setCoreHeaders(res,result,options={}){if(!res||typeof res.setHeader!=='function'||res.headersSent){return;}const rawDiff=result.resetTime?(result.resetTime.getTime()-Date.now())/1000:0;const resetSeconds=Number.isFinite(rawDiff)?Math.max(0,Math.ceil(rawDiff)):0;if(options.standardHeaders){res.setHeader('RateLimit-Limit',String(result.limit));res.setHeader('RateLimit-Remaining',String(result.remaining));res.setHeader('RateLimit-Reset',String(resetSeconds));const policyWindowMs=options.windowMs||options.limiter&&(options.limiter.msDuration||(options.limiter.duration?options.limiter.duration*1000:null))||60000;if(options.standardHeaders!=='draft-6'){const windowSec=Math.ceil(policyWindowMs/1000);res.setHeader('RateLimit-Policy',`${result.limit};w=${windowSec}`);}}if(options.legacyHeaders){res.setHeader('X-RateLimit-Limit',String(result.limit));res.setHeader('X-RateLimit-Remaining',String(result.remaining));res.setHeader('X-RateLimit-Reset',String(resetSeconds));}if(!result.allowed){const retryAfter=Math.max(1,Number(result.retryAfter)||resetSeconds||1);res.setHeader('Retry-After',String(retryAfter));}}export default createCoreMiddleware;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import{RateLimiterStore}from'../rateLimiterStore.js';import{RedisClientAdapter}from'../../utils/redisClient.js';const INCR_TTL_LUA=`
|
|
2
|
+
local ttl = redis.call('pttl', KEYS[1])
|
|
3
|
+
if ttl == -2 then
|
|
4
|
+
redis.call('set', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
|
5
|
+
return {ARGV[1], ARGV[2]}
|
|
6
|
+
end
|
|
7
|
+
local consumed = redis.call('incrby', KEYS[1], ARGV[1])
|
|
8
|
+
if ttl == -1 then
|
|
9
|
+
redis.call('pexpire', KEYS[1], ARGV[2])
|
|
10
|
+
ttl = tonumber(ARGV[2])
|
|
11
|
+
end
|
|
12
|
+
return {consumed, ttl}
|
|
13
|
+
`;const SET_TTL_LUA=`
|
|
14
|
+
redis.call('set', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
|
15
|
+
return {ARGV[1], ARGV[2]}
|
|
16
|
+
`;export class ClusterLimiter extends RateLimiterStore{constructor(opts={}){super(opts);const client=opts.storeClient||opts.client||opts.redis||null;if(!client){throw new Error('Redis cluster client is not configured. Pass it via the "storeClient" option.');}this.client=client;this.adapter=new RedisClientAdapter(client,{clientKind:opts.clientKind});this.hashTag=opts.hashTag!==undefined?Boolean(opts.hashTag):true;this._incrTtlScript=opts.customIncrTtlLuaScript||INCR_TTL_LUA;this._setTtlScript=opts.customSetTtlLuaScript||SET_TTL_LUA;}getKey(key){const prefixed=super.getKey(key);if(!this.hashTag)return prefixed;if(prefixed.includes('{'))return prefixed;return`{${prefixed}}`;}async _upsert(rlKey,points,msDuration,forceExpire=false){if(!Number.isInteger(points)){throw new Error('Consuming decimal number of points is not supported by this package');}const ttl=Math.max(1,Math.floor(msDuration));const script=forceExpire?this._setTtlScript:this._incrTtlScript;const raw=await this.adapter.evalScript(script,[rlKey],[points,ttl]);const consumed=Array.isArray(raw)?Number(raw[0]):Number(raw);const resTtlMs=Array.isArray(raw)?Number(raw[1]):ttl;return{consumedPoints:consumed,expireAt:msDuration>0?Date.now()+resTtlMs:null};}async _get(rlKey){const entry=await this.adapter.getWithTtl(rlKey);if(!entry)return null;const consumedPoints=Number(entry.value);if(!Number.isFinite(consumedPoints))return null;return{consumedPoints,expireAt:entry.ttlMs>0?Date.now()+entry.ttlMs:null};}async _delete(rlKey){return this.adapter.del(rlKey);}}export const RedisClusterLimiter=ClusterLimiter;export default ClusterLimiter;
|