express-adaptive-rate-limiter 1.0.0 → 2.0.1
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 +434 -17
- 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 +1 -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 +525 -1
- 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 +72 -11
package/README.md
CHANGED
|
@@ -1,8 +1,36 @@
|
|
|
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.1
|
|
15
|
+
|
|
16
|
+
- **Full Redis Cluster support**:
|
|
17
|
+
- **Cross-Slot (`CROSSSLOT`) Protection**: Safe atomic key deletion (`delMany`) via pipeline or per-slot execution preventing Redis Cluster `CROSSSLOT` failures.
|
|
18
|
+
- **Multi-Master Cluster Scanning**: `scanKeys` and `store.clear()` query every cluster master node (`nodes('master')` in `ioredis`, `masters` in `node-redis`) to locate and clear keys across all shards without missing any nodes.
|
|
19
|
+
- **Automatic Hash Tagging**: `hashTag: true` automatically wraps keys in `{keyPrefix:key}` so all operations for a given rate limit target map to the exact same hash slot on the cluster.
|
|
20
|
+
- **Cluster Limiter Parity**: Full drop-in compatibility with `RateLimiterRedisCluster`, `ClusterLimiter`, and `RedisClusterLimiter`, inheriting `rejectIfRedisNotReady` and `parseKey` key unwrapping.
|
|
21
|
+
- **Direct Express Tier Integration**: Configure `rateLimit({ store: 'redis-cluster' })` or `rateLimit({ store: 'cluster' })` with automated cluster client creation and node discovery.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## What's new in 2.0.0
|
|
26
|
+
|
|
27
|
+
**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).
|
|
28
|
+
|
|
29
|
+
**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.
|
|
30
|
+
|
|
31
|
+
**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`.
|
|
32
|
+
|
|
33
|
+
**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
34
|
|
|
7
35
|
---
|
|
8
36
|
|
|
@@ -14,23 +42,27 @@ npm install express-adaptive-rate-limiter
|
|
|
14
42
|
|
|
15
43
|
### Optional Peer Dependencies
|
|
16
44
|
|
|
17
|
-
Install only
|
|
18
|
-
|
|
19
|
-
- **Redis
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
- **
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
Install only the driver for the backend you use:
|
|
46
|
+
|
|
47
|
+
- **Redis**: `npm install ioredis` or `npm install redis`
|
|
48
|
+
- **MongoDB**: `npm install mongodb`
|
|
49
|
+
- **Mongoose**: `npm install mongoose`
|
|
50
|
+
- **MySQL**: `npm install mysql2`
|
|
51
|
+
- **PostgreSQL**: `npm install pg`
|
|
52
|
+
- **SQLite**: `npm install better-sqlite3` (Node 22.5+ can use the built-in `node:sqlite`)
|
|
53
|
+
- **Prisma**: `npm install @prisma/client`
|
|
54
|
+
- **Memcached**: `npm install memjs` or `npm install memcached`
|
|
55
|
+
- **DynamoDB**: `npm install @aws-sdk/client-dynamodb`
|
|
56
|
+
- **Valkey**: `npm install iovalkey` or `npm install @valkey/valkey-glide`
|
|
57
|
+
- **etcd**: `npm install etcd3`
|
|
58
|
+
- **GraphQL root field key generation**: `npm install graphql`
|
|
29
59
|
|
|
30
60
|
---
|
|
31
61
|
|
|
32
62
|
## Quick Start
|
|
33
63
|
|
|
64
|
+
### Express middleware
|
|
65
|
+
|
|
34
66
|
```javascript
|
|
35
67
|
import express from 'express';
|
|
36
68
|
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
@@ -53,13 +85,42 @@ app.get('/', (req, res) => {
|
|
|
53
85
|
app.listen(3000);
|
|
54
86
|
```
|
|
55
87
|
|
|
88
|
+
### Framework-agnostic core limiter
|
|
89
|
+
|
|
90
|
+
```javascript
|
|
91
|
+
import { MemoryLimiter } from 'express-adaptive-rate-limiter';
|
|
92
|
+
|
|
93
|
+
const limiter = new MemoryLimiter({ points: 100, duration: 60 });
|
|
94
|
+
|
|
95
|
+
const res = await limiter.consume('user-42');
|
|
96
|
+
console.log(res.remainingPoints, res.msBeforeNext);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Core limiter behind Express
|
|
100
|
+
|
|
101
|
+
Any core limiter can be turned into Express middleware:
|
|
102
|
+
|
|
103
|
+
```javascript
|
|
104
|
+
import express from 'express';
|
|
105
|
+
import { MongoLimiter, createCoreMiddleware } from 'express-adaptive-rate-limiter';
|
|
106
|
+
|
|
107
|
+
const app = express();
|
|
108
|
+
|
|
109
|
+
app.use(
|
|
110
|
+
createCoreMiddleware(
|
|
111
|
+
new MongoLimiter({ points: 100, duration: 60, dbName: 'my-app' }),
|
|
112
|
+
{ standardHeaders: true }
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
```
|
|
116
|
+
|
|
56
117
|
---
|
|
57
118
|
|
|
58
119
|
## Supported Algorithms
|
|
59
120
|
|
|
60
121
|
| Algorithm | Identifier | Best For | Memory | Redis | Atomic |
|
|
61
122
|
| --------------------------- | ------------------------ | -------------------------------------------- | :----: | :------: | :----: |
|
|
62
|
-
| **Fixed Window Counter** | `fixed-window` | Low-overhead,
|
|
123
|
+
| **Fixed Window Counter** | `fixed-window` | Low-overhead, window anchored on first hit | ✅ | ✅ (Lua) | ✅ |
|
|
63
124
|
| **Sliding Window Log** | `sliding-window-log` | 100% strict precision rolling window | ✅ | ✅ (Lua) | ✅ |
|
|
64
125
|
| **Sliding Window Counter** | `sliding-window-counter` | Smooth rolling limits with O(1) memory | ✅ | ✅ (Lua) | ✅ |
|
|
65
126
|
| **Token Bucket** | `token-bucket` | Bursts allowed with steady background refill | ✅ | ✅ (Lua) | ✅ |
|
|
@@ -68,6 +129,313 @@ app.listen(3000);
|
|
|
68
129
|
|
|
69
130
|
---
|
|
70
131
|
|
|
132
|
+
## Core Tier: Framework-Agnostic Limiters
|
|
133
|
+
|
|
134
|
+
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.
|
|
135
|
+
|
|
136
|
+
### The API
|
|
137
|
+
|
|
138
|
+
Every core limiter exposes the same surface:
|
|
139
|
+
|
|
140
|
+
| Method | Description |
|
|
141
|
+
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
142
|
+
| `consume(key, points?, options?)` | Consume points. Resolves with a `RateLimiterRes`, or **rejects** with one when the limit is exceeded. |
|
|
143
|
+
| `penalty(key, points?, options?)` | Increase the consumed points without moving the window. |
|
|
144
|
+
| `reward(key, points?, options?)` | Decrease the consumed points without moving the window. |
|
|
145
|
+
| `get(key, options?)` | Read the current state, or `null` when the key is unknown. |
|
|
146
|
+
| `set(key, points, secDuration?, options?)` | Overwrite the counter and restart the window. |
|
|
147
|
+
| `block(key, secDuration, options?)` | Block a key for a number of seconds. |
|
|
148
|
+
| `delete(key, options?)` | Reset a key in the store and clear in-memory blocks. Returns whether it existed. |
|
|
149
|
+
| `deleteInMemoryBlocked(key)` | Remove only the local in-process block for a specific key. |
|
|
150
|
+
| `deleteInMemoryBlockedAll()` | Remove all active in-process blocks. |
|
|
151
|
+
| `getKey(key)` / `parseKey(rlKey)` | Prefix handling for the storage key. |
|
|
152
|
+
|
|
153
|
+
`RateLimiterRes` carries the following fields:
|
|
154
|
+
|
|
155
|
+
```javascript
|
|
156
|
+
{
|
|
157
|
+
remainingPoints: 99, // points still available
|
|
158
|
+
msBeforeNext: 59832, // ms until the window resets (-1 = never)
|
|
159
|
+
consumedPoints: 1, // points consumed in this window
|
|
160
|
+
isFirstInDuration: true // whether this call opened the window
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Common options
|
|
165
|
+
|
|
166
|
+
| Option | Type | Default | Description |
|
|
167
|
+
| ------------------------ | ----------------- | ------- | ------------------------------------------------------------------------- |
|
|
168
|
+
| `points` | `number` | `60` | Maximum points consumable within the window. `limit` is an alias. |
|
|
169
|
+
| `duration` | `number` | `60` | Window length in **seconds**. `0` means points are never reset. |
|
|
170
|
+
| `windowMs` | `number` | — | Alias of `duration`, in milliseconds. |
|
|
171
|
+
| `blockDuration` | `number` | `0` | Seconds a key stays blocked once it exceeds its limit. |
|
|
172
|
+
| `execEvenly` | `boolean` | `false` | Spread allowed requests evenly across the window. |
|
|
173
|
+
| `execEvenlyMinDelayMs` | `number` | derived | Minimum delay between two evenly spread executions. |
|
|
174
|
+
| `keyPrefix` | `string` | `'rl:'` | Prefix applied to every stored key. |
|
|
175
|
+
| `insuranceLimiter` | `RateLimiter` | `null` | Fallback limiter used when the primary store fails. |
|
|
176
|
+
| `inMemoryBlockOnConsumed`| `number` | `0` | Block a key in process memory once this many points are consumed. |
|
|
177
|
+
| `inMemoryBlockDuration` | `number` | `0` | Seconds the in-memory block lasts. |
|
|
178
|
+
| `clearExpiredByTimeout` | `boolean` | `false` | Automatically purge expired rows from SQL/Mongo tables periodically. |
|
|
179
|
+
| `clearExpiredTimeoutMs` | `number` | `300000`| Interval for expired key purge timer (default 5 minutes). |
|
|
180
|
+
|
|
181
|
+
### Backends & Class Aliases
|
|
182
|
+
|
|
183
|
+
Both modern descriptive names and classic class aliases are exported for convenience:
|
|
184
|
+
|
|
185
|
+
| Descriptive Limiter | Class Alias | Driver | Atomic mechanism |
|
|
186
|
+
| ------------------- | ----------------------------- | ----------------------------------------- | ------------------------------------ |
|
|
187
|
+
| `MemoryLimiter` | `RateLimiterMemory` | none | in-process Map with O(1) eviction |
|
|
188
|
+
| `RedisLimiter` | `RateLimiterRedis` | `ioredis` or `redis` | Lua script |
|
|
189
|
+
| `RateLimiterCluster`| `RateLimiterCluster` | `node:cluster` IPC | Master-Worker IPC messaging |
|
|
190
|
+
| `RedisClusterLimiter`| `ClusterLimiter` / `RateLimiterRedisCluster` | ioredis Cluster / node-redis cluster | Lua script, hash-tagged `{slot}` |
|
|
191
|
+
| `ValkeyLimiter` | `RateLimiterValkey` | `iovalkey` or `@valkey/valkey-glide` | Lua script |
|
|
192
|
+
| `MongoLimiter` | `RateLimiterMongo` | `mongodb` | `findOneAndUpdate` with `$inc` |
|
|
193
|
+
| `MongooseLimiter` | `RateLimiterMongoose` | `mongoose` | `findOneAndUpdate` with `$inc` |
|
|
194
|
+
| `DynamoDbLimiter` | `RateLimiterDynamo` / `DynamoDb` | `@aws-sdk/client-dynamodb` | `UpdateItem` with `ADD` |
|
|
195
|
+
| `MySqlLimiter` | `RateLimiterMySQL` | `mysql2` | `INSERT ... ON DUPLICATE KEY UPDATE` |
|
|
196
|
+
| `PostgresLimiter` | `RateLimiterPostgres` | `pg` | `INSERT ... ON CONFLICT DO UPDATE` |
|
|
197
|
+
| `SqliteLimiter` | `RateLimiterSQLite` | `better-sqlite3` or built-in `node:sqlite`| `INSERT ... ON CONFLICT DO UPDATE` |
|
|
198
|
+
| `PrismaLimiter` | `RateLimiterPrisma` | `@prisma/client` | raw upsert, Postgres or MySQL |
|
|
199
|
+
| `MemcachedLimiter` | `RateLimiterMemcache` / `Memcached` | `memjs` / `memcached` | `add` for creation, `incr`/`decr` |
|
|
200
|
+
| `EtcdLimiter` | `RateLimiterEtcd` | `etcd3` | compare-and-swap transaction |
|
|
201
|
+
|
|
202
|
+
Each backend accepts its driver through `storeClient` (with `client` as an alias).
|
|
203
|
+
|
|
204
|
+
### Node.js Cluster Rate Limiting (`RateLimiterCluster` & `RateLimiterClusterMaster`)
|
|
205
|
+
|
|
206
|
+
Rate-limit across multiple CPU cores without Redis using Node's native Inter-Process Communication (IPC):
|
|
207
|
+
|
|
208
|
+
```javascript
|
|
209
|
+
import cluster from 'node:cluster';
|
|
210
|
+
import { RateLimiterClusterMaster, RateLimiterCluster } from 'express-adaptive-rate-limiter';
|
|
211
|
+
|
|
212
|
+
if (cluster.isPrimary || cluster.isMaster) {
|
|
213
|
+
// Master process initializes the central coordinator
|
|
214
|
+
new RateLimiterClusterMaster();
|
|
215
|
+
|
|
216
|
+
// Fork worker processes
|
|
217
|
+
for (let i = 0; i < 4; i++) {
|
|
218
|
+
cluster.fork();
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
// Worker process rate limiter
|
|
222
|
+
const limiter = new RateLimiterCluster({
|
|
223
|
+
keyPrefix: 'cluster_rl',
|
|
224
|
+
points: 10,
|
|
225
|
+
duration: 1,
|
|
226
|
+
timeoutMs: 3000 // failsafe timeout
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const res = await limiter.consume('user-ip');
|
|
230
|
+
console.log(res.remainingPoints);
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Redis Cluster Rate Limiting (`ClusterLimiter` & `store: 'redis-cluster'`)
|
|
235
|
+
|
|
236
|
+
Rate limiting across a distributed Redis Cluster requires careful handling of key routing. Redis Cluster partitions keys across 16,384 hash slots. Multi-key operations or atomic scripts touching related keys will fail with `CROSSSLOT Keys in request don't hash to the same slot` unless configured with **hash tags (`{...}`)**.
|
|
237
|
+
|
|
238
|
+
`express-adaptive-rate-limiter` provides **first-class Redis Cluster support** across both the Express middleware and Core tiers:
|
|
239
|
+
- **Automatic Hash Tagging**: Wraps keys in `{keyPrefix:key}` by default so multi-key operations land on the exact same hash slot.
|
|
240
|
+
- **Cross-Slot Safe Cleanups**: Key deletions (`delMany`) use Redis pipelines or individual per-slot deletions, preventing `CROSSSLOT` errors.
|
|
241
|
+
- **Cluster-Wide Master Scanning**: `store.clear()` queries all cluster master nodes (`cluster.nodes('master')` in `ioredis`, `cluster.masters` in `node-redis`) to ensure no dangling keys remain across shards.
|
|
242
|
+
- **Client Flexibility**: Works with both `ioredis.Cluster` and `@redis/client` (`node-redis`) cluster mode.
|
|
243
|
+
|
|
244
|
+
#### 1. Express Middleware with Redis Cluster
|
|
245
|
+
|
|
246
|
+
##### Using `ioredis.Cluster`:
|
|
247
|
+
|
|
248
|
+
```javascript
|
|
249
|
+
import express from 'express';
|
|
250
|
+
import Redis from 'ioredis';
|
|
251
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
252
|
+
|
|
253
|
+
const app = express();
|
|
254
|
+
|
|
255
|
+
// Initialize ioredis Cluster client
|
|
256
|
+
const clusterClient = new Redis.Cluster(
|
|
257
|
+
[
|
|
258
|
+
{ host: '10.0.0.1', port: 6379 },
|
|
259
|
+
{ host: '10.0.0.2', port: 6379 },
|
|
260
|
+
{ host: '10.0.0.3', port: 6379 }
|
|
261
|
+
],
|
|
262
|
+
{
|
|
263
|
+
redisOptions: {
|
|
264
|
+
enableOfflineQueue: false // recommended: fail fast on network partitions
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Configure Express rate limiter with Redis Cluster
|
|
270
|
+
const clusterLimiter = rateLimit({
|
|
271
|
+
store: 'redis-cluster', // or 'cluster'
|
|
272
|
+
client: clusterClient,
|
|
273
|
+
algorithm: 'sliding-window-counter', // any of the 5 algorithms
|
|
274
|
+
limit: 100, // 100 requests
|
|
275
|
+
windowMs: 60 * 1000, // per 1 minute
|
|
276
|
+
hashTag: true, // wraps storage keys in {prefix:key} (default: true)
|
|
277
|
+
failOpen: true // allow traffic if cluster is unreachable
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
app.use(clusterLimiter);
|
|
281
|
+
|
|
282
|
+
app.get('/api/resource', (req, res) => {
|
|
283
|
+
res.json({ message: 'Within cluster rate limit', rateLimit: req.rateLimit });
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
##### Using `node-redis` (`createCluster`):
|
|
288
|
+
|
|
289
|
+
```javascript
|
|
290
|
+
import express from 'express';
|
|
291
|
+
import { createCluster } from 'redis';
|
|
292
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
293
|
+
|
|
294
|
+
const app = express();
|
|
295
|
+
|
|
296
|
+
const cluster = createCluster({
|
|
297
|
+
rootNodes: [{ url: 'redis://10.0.0.1:6379' }, { url: 'redis://10.0.0.2:6379' }]
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await cluster.connect();
|
|
301
|
+
|
|
302
|
+
app.use(
|
|
303
|
+
rateLimit({
|
|
304
|
+
store: 'redis-cluster',
|
|
305
|
+
client: cluster,
|
|
306
|
+
limit: 60,
|
|
307
|
+
windowMs: 60000
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
##### Auto-Connecting Cluster (without manual client instantiation):
|
|
313
|
+
|
|
314
|
+
You can also pass cluster nodes directly to `rateLimit` or `RedisStore`:
|
|
315
|
+
|
|
316
|
+
```javascript
|
|
317
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
318
|
+
|
|
319
|
+
app.use(
|
|
320
|
+
rateLimit({
|
|
321
|
+
store: 'redis-cluster',
|
|
322
|
+
cluster: [
|
|
323
|
+
{ host: '10.0.0.1', port: 6379 },
|
|
324
|
+
{ host: '10.0.0.2', port: 6379 }
|
|
325
|
+
],
|
|
326
|
+
limit: 100,
|
|
327
|
+
windowMs: 60000
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
#### 2. Framework-Agnostic Core Tier (`ClusterLimiter` / `RateLimiterRedisCluster`)
|
|
335
|
+
|
|
336
|
+
For non-Express applications, microservices, GraphQL resolvers, or background queues, use `ClusterLimiter` (also exported as `RedisClusterLimiter` and `RateLimiterRedisCluster` for 100% `rate-limiter-flexible` drop-in parity):
|
|
337
|
+
|
|
338
|
+
```javascript
|
|
339
|
+
import Redis from 'ioredis';
|
|
340
|
+
import { ClusterLimiter } from 'express-adaptive-rate-limiter';
|
|
341
|
+
|
|
342
|
+
const redisCluster = new Redis.Cluster([
|
|
343
|
+
{ host: '10.0.0.1', port: 6379 },
|
|
344
|
+
{ host: '10.0.0.2', port: 6379 }
|
|
345
|
+
]);
|
|
346
|
+
|
|
347
|
+
const limiter = new ClusterLimiter({
|
|
348
|
+
storeClient: redisCluster,
|
|
349
|
+
points: 10, // 10 points
|
|
350
|
+
duration: 1, // per 1 second
|
|
351
|
+
keyPrefix: 'cluster_api',
|
|
352
|
+
hashTag: true, // wraps keys in {cluster_api:key}
|
|
353
|
+
rejectIfRedisNotReady: true // reject immediately if cluster is not ready
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Consume points
|
|
357
|
+
try {
|
|
358
|
+
const res = await limiter.consume('user_42', 1);
|
|
359
|
+
console.log('Points remaining:', res.remainingPoints);
|
|
360
|
+
} catch (rejRes) {
|
|
361
|
+
if (rejRes instanceof Error) {
|
|
362
|
+
console.error('Cluster storage error:', rejRes);
|
|
363
|
+
} else {
|
|
364
|
+
console.warn(`Rate limit exceeded! Retry after ${rejRes.msBeforeNext}ms`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Penalty and reward
|
|
369
|
+
await limiter.penalty('user_42', 2);
|
|
370
|
+
await limiter.reward('user_42', 1);
|
|
371
|
+
|
|
372
|
+
// Block a client across the cluster
|
|
373
|
+
await limiter.block('suspicious_user', 3600); // blocked for 1 hour
|
|
374
|
+
|
|
375
|
+
// Read state without consuming
|
|
376
|
+
const state = await limiter.get('user_42');
|
|
377
|
+
console.log(state?.consumedPoints);
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
#### Redis Cluster Options Reference
|
|
381
|
+
|
|
382
|
+
| Option | Type | Default | Description |
|
|
383
|
+
| --- | --- | --- | --- |
|
|
384
|
+
| `storeClient` / `client` | `object` | `null` | Active `ioredis.Cluster` or `node-redis` cluster instance. |
|
|
385
|
+
| `cluster` / `clusterNodes` | `Array` | `null` | Array of cluster node definitions to automatically instantiate. |
|
|
386
|
+
| `hashTag` | `boolean` | `true` | Wraps keys in `{...}` hash tags so multi-key operations land in the same slot. |
|
|
387
|
+
| `rejectIfRedisNotReady` | `boolean` | `false` | Throws `RedisConnectionError` if cluster status is not ready. |
|
|
388
|
+
| `failOpen` | `boolean` | `false` | When true, errors from the store fail open instead of failing closed. |
|
|
389
|
+
|
|
390
|
+
### In-Memory State Dump & Restore (`dump()` & `restore()`)
|
|
391
|
+
|
|
392
|
+
Save in-memory state on shutdown for zero-downtime restarts:
|
|
393
|
+
|
|
394
|
+
```javascript
|
|
395
|
+
import fs from 'node:fs';
|
|
396
|
+
import { RateLimiterMemory } from 'express-adaptive-rate-limiter';
|
|
397
|
+
|
|
398
|
+
const limiter = new RateLimiterMemory({ points: 10, duration: 60 });
|
|
399
|
+
|
|
400
|
+
// On graceful shutdown
|
|
401
|
+
const snapshot = limiter.dump();
|
|
402
|
+
fs.writeFileSync('./rate-limit-state.json', JSON.stringify(snapshot));
|
|
403
|
+
|
|
404
|
+
// On application startup
|
|
405
|
+
if (fs.existsSync('./rate-limit-state.json')) {
|
|
406
|
+
const data = JSON.parse(fs.readFileSync('./rate-limit-state.json', 'utf8'));
|
|
407
|
+
const report = limiter.restore(data);
|
|
408
|
+
console.log(`Restored ${report.restored} keys, ${report.expired} expired`);
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Wrappers
|
|
413
|
+
|
|
414
|
+
| Wrapper | Purpose |
|
|
415
|
+
| ---------------------------------------- | ---------------------------------------------------------------- |
|
|
416
|
+
| `RateLimiterQueue` | Queues requests instead of rejecting them when the limit is full.|
|
|
417
|
+
| `RateLimiterUnion` | Requires every wrapped limiter to allow the request. |
|
|
418
|
+
| `BurstyRateLimiter` | A steady limiter with a burst fallback. |
|
|
419
|
+
| `BlackAndWhiteLimiter` / `RateLimiterBlackAndWhite` | Black and white lists in front of a limiter. |
|
|
420
|
+
| `TimeoutLimiter` | Fails a call that takes longer than `timeoutMs`. |
|
|
421
|
+
|
|
422
|
+
```javascript
|
|
423
|
+
import { MemoryLimiter, RateLimiterUnion, BlackAndWhiteLimiter } from 'express-adaptive-rate-limiter';
|
|
424
|
+
|
|
425
|
+
const perIp = new MemoryLimiter({ points: 100, duration: 60 });
|
|
426
|
+
const perAccount = new MemoryLimiter({ points: 10, duration: 60, keyPrefix: 'acct' });
|
|
427
|
+
|
|
428
|
+
const both = new RateLimiterUnion(perIp, perAccount);
|
|
429
|
+
|
|
430
|
+
const guarded = new BlackAndWhiteLimiter({
|
|
431
|
+
limiter: both,
|
|
432
|
+
whiteList: ['10.0.0.0/8'],
|
|
433
|
+
blackList: ['203.0.113.5']
|
|
434
|
+
});
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
71
439
|
## Configuration Reference
|
|
72
440
|
|
|
73
441
|
### 1. Common Options
|
|
@@ -107,7 +475,7 @@ All options below can be passed to `rateLimit(options)`:
|
|
|
107
475
|
|
|
108
476
|
#### Fixed Window (`algorithm: 'fixed-window'`)
|
|
109
477
|
|
|
110
|
-
|
|
478
|
+
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
479
|
|
|
112
480
|
| Option | Type | Default | Description |
|
|
113
481
|
| --------------- | -------- | ------- | ------------------------------------------- |
|
|
@@ -229,6 +597,8 @@ rateLimit({
|
|
|
229
597
|
|
|
230
598
|
### 4. Storage Backends
|
|
231
599
|
|
|
600
|
+
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.
|
|
601
|
+
|
|
232
602
|
#### MemoryStore Options
|
|
233
603
|
|
|
234
604
|
```javascript
|
|
@@ -604,6 +974,8 @@ Retry-After: 45
|
|
|
604
974
|
| `validateIpRangeV4` | `(ip, whitelist?) => boolean` | Tests IP against whitelist or `Platform.WHITELIST_IPS`. |
|
|
605
975
|
| `validateSecondaryIps` | `(req, whitelist?) => boolean` | Validates `x-real-ip` or `x-forwarded-for` when `x-xcld: true`. |
|
|
606
976
|
| `evaluateSkipRule` | `(rule, req, res, context?) => Promise<boolean>` | Evaluates skip condition across headers, origins, IPs, keys. |
|
|
977
|
+
| `createCoreMiddleware` | `(limiter, options?) => middleware` | Turns a core limiter into Express middleware. |
|
|
978
|
+
| `detectRedisClientKind` | `(client) => string` | Reports which Redis client family is in use. |
|
|
607
979
|
|
|
608
980
|
---
|
|
609
981
|
|
|
@@ -616,6 +988,7 @@ All error classes inherit from `RateLimiterError`:
|
|
|
616
988
|
- `RedisConnectionError`: Thrown when Redis connection, command, or script execution fails.
|
|
617
989
|
- `QueueFullError`: Thrown in Leaky Bucket shaping mode when `maxQueueSize` is exceeded.
|
|
618
990
|
- `QueueTimeoutError`: Thrown in Leaky Bucket shaping mode when a queued request times out (`queueTimeoutMs`).
|
|
991
|
+
- `RateLimiterQueueError`: Thrown by `RateLimiterQueue` when the queue is full or a queued request expires.
|
|
619
992
|
|
|
620
993
|
---
|
|
621
994
|
|
|
@@ -651,6 +1024,50 @@ app.get('/info', (req, res) => {
|
|
|
651
1024
|
});
|
|
652
1025
|
```
|
|
653
1026
|
|
|
1027
|
+
## Performance Benchmarks
|
|
1028
|
+
|
|
1029
|
+
Run the benchmark suite with `npm run bench`:
|
|
1030
|
+
|
|
1031
|
+
```
|
|
1032
|
+
===============================================================================================
|
|
1033
|
+
Express Adaptive Rate Limiter - Performance Benchmark Suite
|
|
1034
|
+
===============================================================================================
|
|
1035
|
+
| Algorithm & Store | Reqs | Throughput | Mean | p50 | p95 | p99 |
|
|
1036
|
+
-----------------------------------------------------------------------------------------------
|
|
1037
|
+
| Fixed Window (Memory) | 10000 | 254,535 req/s | 0.078 ms | 0.049 ms | 0.172 ms | 0.654 ms |
|
|
1038
|
+
| Sliding Window Counter (Memory) | 10000 | 264,659 req/s | 0.075 ms | 0.060 ms | 0.116 ms | 0.786 ms |
|
|
1039
|
+
| Token Bucket (Memory) | 10000 | 321,866 req/s | 0.061 ms | 0.047 ms | 0.087 ms | 0.555 ms |
|
|
1040
|
+
| Leaky Bucket Policing (Memory) | 10000 | 188,326 req/s | 0.105 ms | 0.079 ms | 0.246 ms | 0.749 ms |
|
|
1041
|
+
| Sliding Window Log (Memory) | 10000 | 121,287 req/s | 0.164 ms | 0.127 ms | 0.227 ms | 0.826 ms |
|
|
1042
|
+
| Core Points Limiter (Memory) | 10000 | 205,303 req/s | 0.095 ms | 0.070 ms | 0.190 ms | 0.660 ms |
|
|
1043
|
+
| Core Points Limiter (Redis) | 1000 | 1,200+ req/s| 0.810 ms | 0.720 ms | 1.450 ms | 2.100 ms |
|
|
1044
|
+
===============================================================================================
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
Key performance highlights:
|
|
1048
|
+
- **Zero-overhead memory tier**: 250,000–320,000 requests/sec with p50 latency under 0.06 ms.
|
|
1049
|
+
- **Atomic Redis scripts**: Single round-trip Lua scripts for atomic increments and TTL sets without race conditions.
|
|
1050
|
+
- **In-memory blocking shield**: Protects Redis or SQL databases against high-frequency brute-force/DoS attacks by caching blocks in local process memory (`inMemoryBlockOnConsumed`).
|
|
1051
|
+
- **O(1) LRU eviction**: High memory ceilings without garbage collection pauses.
|
|
1052
|
+
|
|
1053
|
+
---
|
|
1054
|
+
|
|
1055
|
+
## Issues & Support
|
|
1056
|
+
|
|
1057
|
+
If you encounter any bug, issue, or have any questions:
|
|
1058
|
+
- **npm Package Page**: You can report issues or reach out via the package page on npm:
|
|
1059
|
+
👉 **[npm: express-adaptive-rate-limiter](https://www.npmjs.com/package/express-adaptive-rate-limiter)**
|
|
1060
|
+
- **Developer Profile**: You can also reach out directly through the maintainer's profile:
|
|
1061
|
+
👉 **[GitHub Profile: @ravinddeveloper](https://github.com/ravinddeveloper)**
|
|
1062
|
+
|
|
1063
|
+
Our team actively investigates and resolves all reported issues. When reporting a bug or requesting assistance, please include:
|
|
1064
|
+
- Node.js version (`node -v`)
|
|
1065
|
+
- Package version (`2.0.1`)
|
|
1066
|
+
- Storage driver & version (e.g., `ioredis@5.4.2` or `redis@4.7.0`)
|
|
1067
|
+
- A minimal reproducible example or stack trace
|
|
1068
|
+
|
|
1069
|
+
---
|
|
1070
|
+
|
|
654
1071
|
## License
|
|
655
1072
|
|
|
656
|
-
MIT ©
|
|
1073
|
+
MIT © [RAVI N D](https://github.com/ravinddeveloper)
|
|
@@ -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;
|