ratewall 0.1.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 +132 -0
- package/benchmarks/run.js +62 -0
- package/benchmarks/server.js +67 -0
- package/package.json +38 -0
- package/src/check_and_increment.lua +47 -0
- package/src/express-middleware.js +95 -0
- package/src/index.js +14 -0
- package/src/memory-store.js +67 -0
- package/src/redis-store.js +87 -0
- package/src/sliding-window-counter.js +90 -0
- package/test/express-integration.test.js +58 -0
- package/test/express-middleware.test.js +182 -0
- package/test/redis-store.test.js +101 -0
- package/test/sliding-window-counter.test.js +107 -0
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Ratewall
|
|
2
|
+
|
|
3
|
+
A Redis-backed sliding window rate limiter for Node.js, with an Express middleware adapter.
|
|
4
|
+
|
|
5
|
+
## Results
|
|
6
|
+
|
|
7
|
+
Load tested against a real Express server backed by real Redis, 100 concurrent connections, 10 seconds, `max: 50` requests/window on a single shared key (deliberately — that's what stresses the atomicity guarantee under contention):
|
|
8
|
+
|
|
9
|
+
| Metric | Result |
|
|
10
|
+
|---|---|
|
|
11
|
+
| Requests fired | 76,755 |
|
|
12
|
+
| Throughput (avg) | ~7,676 req/sec |
|
|
13
|
+
| Latency p50 / p99 | 11ms / 29ms |
|
|
14
|
+
| Requests allowed | 540 (expected ceiling: ≤550) |
|
|
15
|
+
| Requests correctly blocked | 76,215 |
|
|
16
|
+
|
|
17
|
+
**No leakage under concurrent load** — the number allowed stayed within the limit the algorithm predicts, even with 100 connections hammering the same rate-limit key simultaneously over a real network round trip to Redis. See [What's verified, and how](#whats-verified-and-how) for the full breakdown of every claim and its evidence.
|
|
18
|
+
|
|
19
|
+
## Why not a fixed window?
|
|
20
|
+
|
|
21
|
+
A fixed window counter resets to zero at a hard boundary (e.g. every 60s). That creates a burst loophole: a client can send `max` requests in the last millisecond of one window, then another `max` in the first millisecond of the next — a burst of up to **2x `max`** in a few milliseconds of real time, even though the limiter is "working correctly."
|
|
22
|
+
|
|
23
|
+
## Why not a true sliding window log?
|
|
24
|
+
|
|
25
|
+
A sliding log stores a timestamp per request and is exact, but costs O(n) storage and O(n) work per check, where `n` is the number of requests in the window. That's expensive at real scale (think: a busy API key making thousands of requests per window).
|
|
26
|
+
|
|
27
|
+
## What this implements: the sliding window counter
|
|
28
|
+
|
|
29
|
+
A middle ground. It keeps two adjacent fixed windows — the current one and the previous one — and weights the previous window's count by how much it still overlaps the sliding window ending "now":
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
weightedCount = currentWindowCount + previousWindowCount * (1 - elapsedFractionOfCurrentWindow)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This is **O(1) storage and O(1) work per check**, and it caps bursts much closer to the true limit than a fixed window — at the cost of a small, documented approximation margin right at window boundaries (see `test/sliding-window-counter.test.js`, the `boundary burst` test, for the exact behavior and the math).
|
|
36
|
+
|
|
37
|
+
## The real bug this project surfaced: a check-then-act race condition
|
|
38
|
+
|
|
39
|
+
The naive implementation of "check the count, then increment it" as two separate steps has a race condition under concurrent load:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
// BROKEN — DO NOT DO THIS
|
|
43
|
+
const count = await store.get(key); // step 1: read
|
|
44
|
+
if (count < max) {
|
|
45
|
+
await store.set(key, count + 1); // step 2: write
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If 10 requests arrive concurrently, all 10 can execute step 1 (and all read the *same* pre-increment count) before any of them executes step 2. With `max = 5`, all 10 requests can be allowed through — not 5.
|
|
50
|
+
|
|
51
|
+
**The fix:** collapse read-and-increment into a single atomic operation.
|
|
52
|
+
|
|
53
|
+
- In the in-memory store (`src/memory-store.js`), this means doing both steps synchronously in one tick, with no `await` between them — nothing else can interleave in the middle of a single synchronous block.
|
|
54
|
+
- In the Redis store (`src/redis-store.js` + `src/check_and_increment.lua`), this means running the whole check-and-increment as **one Lua script**, which Redis guarantees executes to completion without any other client's commands interleaving. This is the only way to get the same atomicity guarantee across *multiple app instances* sharing one Redis — the in-memory fix only protects a single process.
|
|
55
|
+
|
|
56
|
+
This was caught by `test/sliding-window-counter.test.js`'s concurrency test, firing 10 simultaneous requests at a `max: 5` limiter and asserting exactly 5 are allowed.
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
const express = require('express');
|
|
62
|
+
const Redis = require('ioredis');
|
|
63
|
+
const { ratewall, RedisStore } = require('ratewall');
|
|
64
|
+
|
|
65
|
+
const redis = new Redis(process.env.REDIS_URL);
|
|
66
|
+
|
|
67
|
+
const app = express();
|
|
68
|
+
app.use(
|
|
69
|
+
ratewall({
|
|
70
|
+
windowMs: 60_000,
|
|
71
|
+
max: 100,
|
|
72
|
+
store: new RedisStore({ redis }),
|
|
73
|
+
keyGenerator: (req) => req.user?.id ?? req.ip, // default is per-IP
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
For single-process use (development, or low-traffic apps that don't need multi-instance correctness), omit `store` and it defaults to an in-memory store:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
app.use(ratewall({ windowMs: 60_000, max: 100 }));
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Failure mode: fail open, not closed
|
|
85
|
+
|
|
86
|
+
If the store throws (e.g. a Redis connection drop), the middleware calls `next(err)` and lets the request through, rather than blocking it. Treating an infrastructure outage as "rate limit exceeded" would turn a Redis blip into an outage for every user simultaneously — that's a worse failure mode than temporarily not rate-limiting at all.
|
|
87
|
+
|
|
88
|
+
## What's verified, and how
|
|
89
|
+
|
|
90
|
+
This project was built across environments with different capabilities, and I want to be precise about which claims are backed by what evidence rather than blur the line:
|
|
91
|
+
|
|
92
|
+
| Claim | Verified by |
|
|
93
|
+
|---|---|
|
|
94
|
+
| Sliding window algorithm math is correct (boundary timing, decay, isolation per key) | `test/sliding-window-counter.test.js` — all passing |
|
|
95
|
+
| The check-then-act race condition is real, and the atomic fix resolves it | `test/sliding-window-counter.test.js`'s concurrency test, **and** an in-process micro-benchmark (5000 concurrent checks against `max=50`, result: exactly 50 allowed, 4950 blocked) |
|
|
96
|
+
| Express middleware logic is correct (headers, custom key generators, fail-open behavior) | `test/express-middleware.test.js` — fake req/res, all passing |
|
|
97
|
+
| Express middleware works inside a **real** Express app/HTTP cycle | `test/express-integration.test.js` — requires `npm install` + real `express`/`supertest`, run locally |
|
|
98
|
+
| RedisStore's argument wiring and return-value parsing is correct | `test/redis-store.test.js` — fake Redis client, all passing |
|
|
99
|
+
| The Lua script actually runs correctly against **real** Redis, with the atomicity guarantee holding over a real network round trip | **Verified.** Ran `benchmarks/server.js` with `RATEWALL_STORE=redis` against a real Redis instance (via WSL), then hit it with `npm run bench` (autocannon, 100 concurrent connections, 10s): **76,755 requests fired, 540 allowed, 76,215 correctly blocked** — within the expected ceiling of ≤550 (50/window × 10 windows, plus sliding-window boundary slack). No leakage under real concurrent load. |
|
|
100
|
+
| Real HTTP-level throughput and latency under load | **Verified.** Same run as above: **~7,676 req/sec average throughput, p50 latency 11ms, p99 latency 29ms**, server backed by real Redis over the network the whole time. |
|
|
101
|
+
|
|
102
|
+
**All of the above is now verified**, including the two rows that originally required a real Redis instance and a real load test — both were run end-to-end (real Redis via WSL, real Express, real network round trips) and the results are recorded above rather than estimated. The full chain — algorithm correctness, the race-condition fix, the Express middleware, and real-world Redis behavior under concurrent load — has each been independently confirmed, not just assumed to follow from unit tests passing.
|
|
103
|
+
|
|
104
|
+
## Running the benchmark locally
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm install
|
|
108
|
+
|
|
109
|
+
# Terminal 1
|
|
110
|
+
node benchmarks/server.js
|
|
111
|
+
# or, against real Redis:
|
|
112
|
+
# RATEWALL_STORE=redis REDIS_URL=redis://localhost:6379 node benchmarks/server.js
|
|
113
|
+
|
|
114
|
+
# Terminal 2
|
|
115
|
+
npm run bench
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
This fires concurrent requests at a single shared rate-limit key (deliberately — that's what actually stresses the atomicity guarantee) and reports both throughput and a correctness check: how many requests were allowed vs. the expected ceiling.
|
|
119
|
+
|
|
120
|
+
## Tests
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npm install
|
|
124
|
+
npm test # full suite, requires express/supertest/ioredis installed
|
|
125
|
+
npm run test:unit # dependency-light subset (no real express/redis needed)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## What's deliberately out of scope (v1)
|
|
129
|
+
|
|
130
|
+
- Token bucket / fixed window implementations — discussed above as the rejected alternatives, not built, to keep this focused and rigorous rather than spread thin across three algorithms.
|
|
131
|
+
- A Fastify adapter — the core (`SlidingWindowCounter`) has no Express dependency, so one is straightforward to add later, but wasn't necessary to prove the core claim.
|
|
132
|
+
- A dashboard/UI — the benchmark script's terminal output covers the same evidence a dashboard would, for a fraction of the build time.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drives autocannon against benchmarks/server.js and reports:
|
|
5
|
+
* 1. Standard load numbers: throughput (req/sec), latency percentiles
|
|
6
|
+
* 2. A CORRECTNESS check: out of all requests fired, how many got a
|
|
7
|
+
* 200 (allowed) vs 429 (blocked)? Since the server is configured
|
|
8
|
+
* with a single shared key and max=50, the number of 200s should
|
|
9
|
+
* be close to 50 * (number of 1-second windows the test spans) —
|
|
10
|
+
* NOT close to the total number of requests fired. If far more
|
|
11
|
+
* 200s come through than that, it's evidence of a race condition
|
|
12
|
+
* letting requests leak past the limit under concurrency.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* 1. In one terminal: node benchmarks/server.js
|
|
16
|
+
* (optionally: RATEWALL_STORE=redis node benchmarks/server.js)
|
|
17
|
+
* 2. In another terminal: node benchmarks/run.js
|
|
18
|
+
*/
|
|
19
|
+
const autocannon = require('autocannon');
|
|
20
|
+
|
|
21
|
+
const URL = process.env.BENCH_URL || 'http://localhost:3001/';
|
|
22
|
+
const DURATION_S = Number(process.env.BENCH_DURATION || 10);
|
|
23
|
+
const CONNECTIONS = Number(process.env.BENCH_CONNECTIONS || 100);
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
console.log(`[bench] hitting ${URL} with ${CONNECTIONS} concurrent connections for ${DURATION_S}s...`);
|
|
27
|
+
|
|
28
|
+
const result = await autocannon({
|
|
29
|
+
url: URL,
|
|
30
|
+
connections: CONNECTIONS,
|
|
31
|
+
duration: DURATION_S,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const total = result.requests.total;
|
|
35
|
+
const non2xx = result.non2xx; // autocannon tracks non-2xx responses (our 429s land here)
|
|
36
|
+
const allowed = total - non2xx;
|
|
37
|
+
const windowsSpanned = Math.ceil(DURATION_S * 1000 / 1000); // windowMs=1000 in server.js
|
|
38
|
+
const expectedMaxAllowed = 50 * windowsSpanned; // MAX=50 in server.js, +/- 1 window of slack
|
|
39
|
+
|
|
40
|
+
console.log('\n--- Throughput ---');
|
|
41
|
+
console.log(`Total requests fired: ${total}`);
|
|
42
|
+
console.log(`Requests/sec (avg): ${result.requests.average}`);
|
|
43
|
+
console.log(`Latency p50/p99 (ms): ${result.latency.p50} / ${result.latency.p99}`);
|
|
44
|
+
|
|
45
|
+
console.log('\n--- Correctness (the actual point of this benchmark) ---');
|
|
46
|
+
console.log(`Allowed (2xx): ${allowed}`);
|
|
47
|
+
console.log(`Blocked (429/non-2xx): ${non2xx}`);
|
|
48
|
+
console.log(`Expected allowed (~): <= ${expectedMaxAllowed} (50/window * ${windowsSpanned} windows, +/- 1 window slack)`);
|
|
49
|
+
|
|
50
|
+
if (allowed > expectedMaxAllowed + 50) {
|
|
51
|
+
console.log(`\n⚠️ Allowed count is well above the expected ceiling — investigate for a race condition leak.`);
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
} else {
|
|
54
|
+
console.log(`\n✅ Allowed count stayed within the expected ceiling under concurrent load.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
main().catch((err) => {
|
|
59
|
+
console.error('[bench] failed:', err.message);
|
|
60
|
+
console.error('Is the benchmark server running? Start it with: node benchmarks/server.js');
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A minimal Express app with ratewall applied, for load testing.
|
|
5
|
+
*
|
|
6
|
+
* Run this, then point autocannon at it (see benchmarks/run.js, or run
|
|
7
|
+
* autocannon directly from the CLI):
|
|
8
|
+
*
|
|
9
|
+
* node benchmarks/server.js
|
|
10
|
+
* npx autocannon -c 100 -d 10 http://localhost:3001/
|
|
11
|
+
*
|
|
12
|
+
* Two modes, controlled by RATEWALL_STORE env var:
|
|
13
|
+
* RATEWALL_STORE=memory (default) - single-process MemoryStore
|
|
14
|
+
* RATEWALL_STORE=redis - RedisStore, requires REDIS_URL env
|
|
15
|
+
* var or localhost:6379 default.
|
|
16
|
+
*
|
|
17
|
+
* The memory-store run tells you the middleware's own overhead.
|
|
18
|
+
* The redis-store run tells you the realistic, network-round-trip cost —
|
|
19
|
+
* and is the one that actually proves atomicity holds over real Redis
|
|
20
|
+
* under concurrent load, not just within one Node process.
|
|
21
|
+
*/
|
|
22
|
+
const express = require('express');
|
|
23
|
+
const { ratewall } = require('../src/express-middleware');
|
|
24
|
+
const { MemoryStore } = require('../src/memory-store');
|
|
25
|
+
|
|
26
|
+
const PORT = process.env.PORT || 3001;
|
|
27
|
+
const WINDOW_MS = 1000;
|
|
28
|
+
const MAX = 50; // generous enough to see real throughput, tight enough to see blocking
|
|
29
|
+
|
|
30
|
+
function buildStore() {
|
|
31
|
+
if (process.env.RATEWALL_STORE === 'redis') {
|
|
32
|
+
// require lazily so a memory-only run never needs ioredis installed
|
|
33
|
+
const Redis = require('ioredis');
|
|
34
|
+
const { RedisStore } = require('../src/redis-store');
|
|
35
|
+
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
|
|
36
|
+
console.log('[benchmark] using RedisStore against', process.env.REDIS_URL || 'redis://localhost:6379');
|
|
37
|
+
return new RedisStore({ redis });
|
|
38
|
+
}
|
|
39
|
+
console.log('[benchmark] using MemoryStore (single-process, no network round trip)');
|
|
40
|
+
return new MemoryStore();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const app = express();
|
|
44
|
+
|
|
45
|
+
// Use a single fixed key for everyone hitting this benchmark server, so
|
|
46
|
+
// autocannon's concurrent connections all compete for the SAME rate-limit
|
|
47
|
+
// budget — that's what actually stresses the atomicity guarantee. If each
|
|
48
|
+
// connection got its own key (e.g. by source port), they'd never contend
|
|
49
|
+
// with each other and the race condition this whole project is about
|
|
50
|
+
// would never get exercised.
|
|
51
|
+
app.use(
|
|
52
|
+
ratewall({
|
|
53
|
+
windowMs: WINDOW_MS,
|
|
54
|
+
max: MAX,
|
|
55
|
+
store: buildStore(),
|
|
56
|
+
keyGenerator: () => 'benchmark-shared-key',
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
app.get('/', (req, res) => {
|
|
61
|
+
res.status(200).json({ ok: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
app.listen(PORT, () => {
|
|
65
|
+
console.log(`[benchmark] server listening on http://localhost:${PORT}`);
|
|
66
|
+
console.log(`[benchmark] window=${WINDOW_MS}ms max=${MAX} (shared key across all callers)`);
|
|
67
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ratewall",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A Redis-backed sliding window rate limiter for Node.js, with an Express middleware adapter.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test test/*.test.js",
|
|
9
|
+
"test:unit": "node --test test/sliding-window-counter.test.js test/redis-store.test.js test/express-middleware.test.js",
|
|
10
|
+
"bench": "node benchmarks/run.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"rate-limit",
|
|
14
|
+
"rate-limiter",
|
|
15
|
+
"redis",
|
|
16
|
+
"express",
|
|
17
|
+
"middleware",
|
|
18
|
+
"sliding-window"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"ioredis": ">=5.0.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"ioredis": {
|
|
29
|
+
"optional": true
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"ioredis": "^5.4.1",
|
|
34
|
+
"express": "^4.19.2",
|
|
35
|
+
"supertest": "^7.0.0",
|
|
36
|
+
"autocannon": "^7.15.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- check_and_increment.lua
|
|
2
|
+
--
|
|
3
|
+
-- Implements the sliding window counter's read-and-maybe-increment as a
|
|
4
|
+
-- SINGLE atomic Redis operation. Redis executes Lua scripts to completion
|
|
5
|
+
-- without interleaving any other client's commands in between — this is
|
|
6
|
+
-- what actually prevents the check-then-act race condition under
|
|
7
|
+
-- concurrent load from multiple app instances hitting the same Redis.
|
|
8
|
+
--
|
|
9
|
+
-- Without this script, the naive approach (GET curr, GET prev, compute,
|
|
10
|
+
-- then SET/INCR) is FOUR separate round trips. Two concurrent clients can
|
|
11
|
+
-- both finish their GETs (both see count=0) before either commits an
|
|
12
|
+
-- INCR, letting both requests through even if max=1. Wrapping the whole
|
|
13
|
+
-- sequence in one Lua script collapses it into a single round trip that
|
|
14
|
+
-- Redis guarantees runs without interruption.
|
|
15
|
+
--
|
|
16
|
+
-- KEYS[1] = current window key e.g. "rw:{key}:1042"
|
|
17
|
+
-- KEYS[2] = previous window key e.g. "rw:{key}:1041"
|
|
18
|
+
-- ARGV[1] = prevWeight (float, 0..1)
|
|
19
|
+
-- ARGV[2] = max (integer)
|
|
20
|
+
-- ARGV[3] = windowMs (integer, used for TTL so abandoned keys expire)
|
|
21
|
+
--
|
|
22
|
+
-- Returns: { allowed (1 or 0), weightedCountBefore * 1000 (as integer,
|
|
23
|
+
-- scaled to avoid Lua/Redis float-return precision issues) }
|
|
24
|
+
|
|
25
|
+
local currKey = KEYS[1]
|
|
26
|
+
local prevKey = KEYS[2]
|
|
27
|
+
local prevWeight = tonumber(ARGV[1])
|
|
28
|
+
local max = tonumber(ARGV[2])
|
|
29
|
+
local windowMs = tonumber(ARGV[3])
|
|
30
|
+
|
|
31
|
+
local currCount = tonumber(redis.call('GET', currKey)) or 0
|
|
32
|
+
local prevCount = tonumber(redis.call('GET', prevKey)) or 0
|
|
33
|
+
|
|
34
|
+
local weightedCount = currCount + (prevCount * prevWeight)
|
|
35
|
+
|
|
36
|
+
if weightedCount >= max then
|
|
37
|
+
return { 0, math.floor(weightedCount * 1000) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
local newCurrCount = redis.call('INCR', currKey)
|
|
41
|
+
-- TTL covers this window plus the next, so it self-expires even if a key
|
|
42
|
+
-- is never touched again — avoids unbounded key growth for one-off callers.
|
|
43
|
+
redis.call('PEXPIRE', currKey, windowMs * 2)
|
|
44
|
+
|
|
45
|
+
local finalWeightedCount = newCurrCount + (prevCount * prevWeight)
|
|
46
|
+
|
|
47
|
+
return { 1, math.floor(finalWeightedCount * 1000) }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { SlidingWindowCounter } = require('./sliding-window-counter');
|
|
4
|
+
const { MemoryStore } = require('./memory-store');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default key generator: rate-limit per client IP address.
|
|
8
|
+
* Honors X-Forwarded-For when Express's trust proxy setting has been
|
|
9
|
+
* configured correctly (req.ip already accounts for that), so this
|
|
10
|
+
* does NOT read X-Forwarded-For directly itself — doing so manually
|
|
11
|
+
* would let a client spoof their own rate-limit identity by setting
|
|
12
|
+
* the header themselves on a server that isn't actually behind a proxy.
|
|
13
|
+
*/
|
|
14
|
+
function ipKeyGenerator(req) {
|
|
15
|
+
return req.ip;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates an Express middleware function backed by a SlidingWindowCounter.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} opts
|
|
22
|
+
* @param {number} opts.windowMs - size of the rate-limit window in ms
|
|
23
|
+
* @param {number} opts.max - max requests allowed per window per key
|
|
24
|
+
* @param {object} [opts.store] - a store implementing checkAndIncrement;
|
|
25
|
+
* defaults to a single-process MemoryStore if omitted. For
|
|
26
|
+
* multi-instance deployments, pass a RedisStore instead — the
|
|
27
|
+
* in-memory default only protects one process and will under-count
|
|
28
|
+
* if you run more than one instance behind a load balancer.
|
|
29
|
+
* @param {(req: import('express').Request) => string} [opts.keyGenerator] -
|
|
30
|
+
* function deriving the rate-limit key from a request. Defaults
|
|
31
|
+
* to per-IP. Pass a custom function for per-user or per-API-key
|
|
32
|
+
* limiting, e.g. (req) => req.user?.id ?? req.ip
|
|
33
|
+
* @param {boolean} [opts.standardHeaders] - if true (default), sets
|
|
34
|
+
* RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers
|
|
35
|
+
* on every response, per the IETF draft conventions.
|
|
36
|
+
* @param {(req, res) => void} [opts.handler] - custom handler invoked when
|
|
37
|
+
* a request is blocked, instead of the default 429 JSON response.
|
|
38
|
+
* @returns {import('express').RequestHandler}
|
|
39
|
+
*/
|
|
40
|
+
function ratewall(opts = {}) {
|
|
41
|
+
const {
|
|
42
|
+
windowMs,
|
|
43
|
+
max,
|
|
44
|
+
store = new MemoryStore(),
|
|
45
|
+
keyGenerator = ipKeyGenerator,
|
|
46
|
+
standardHeaders = true,
|
|
47
|
+
handler,
|
|
48
|
+
} = opts;
|
|
49
|
+
|
|
50
|
+
const limiter = new SlidingWindowCounter({ windowMs, max, store });
|
|
51
|
+
|
|
52
|
+
return async function ratewallMiddleware(req, res, next) {
|
|
53
|
+
let key;
|
|
54
|
+
try {
|
|
55
|
+
key = keyGenerator(req);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// a broken keyGenerator should not take the whole app down —
|
|
58
|
+
// fail open and let the request through, but surface the error.
|
|
59
|
+
return next(err);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let result;
|
|
63
|
+
try {
|
|
64
|
+
result = await limiter.check(key);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// Store errors (e.g. Redis connection drop) should not be treated
|
|
67
|
+
// the same as "rate limit exceeded" — that would turn an infra
|
|
68
|
+
// outage into an outage for every one of your users at once.
|
|
69
|
+
// Fail OPEN: let the request through, but pass the error along so
|
|
70
|
+
// the app can log/alert on it.
|
|
71
|
+
return next(err);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (standardHeaders) {
|
|
75
|
+
res.setHeader('RateLimit-Limit', String(max));
|
|
76
|
+
res.setHeader('RateLimit-Remaining', String(Math.floor(result.remaining)));
|
|
77
|
+
res.setHeader('RateLimit-Reset', String(Math.ceil(result.resetMs / 1000)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!result.allowed) {
|
|
81
|
+
if (typeof handler === 'function') {
|
|
82
|
+
return handler(req, res);
|
|
83
|
+
}
|
|
84
|
+
res.setHeader('Retry-After', String(Math.ceil(result.resetMs / 1000)));
|
|
85
|
+
return res.status(429).json({
|
|
86
|
+
error: 'Too Many Requests',
|
|
87
|
+
retryAfterMs: result.resetMs,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return next();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { ratewall, ipKeyGenerator };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { SlidingWindowCounter } = require('./sliding-window-counter');
|
|
4
|
+
const { MemoryStore } = require('./memory-store');
|
|
5
|
+
const { RedisStore } = require('./redis-store');
|
|
6
|
+
const { ratewall, ipKeyGenerator } = require('./express-middleware');
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
SlidingWindowCounter,
|
|
10
|
+
MemoryStore,
|
|
11
|
+
RedisStore,
|
|
12
|
+
ratewall,
|
|
13
|
+
ipKeyGenerator,
|
|
14
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MemoryStore is a single-process, in-memory implementation of the store
|
|
5
|
+
* interface used by SlidingWindowCounter. It exists so the algorithm can
|
|
6
|
+
* be unit-tested fast, without Redis, and so the atomicity contract can
|
|
7
|
+
* be verified in isolation before introducing network/IO concerns.
|
|
8
|
+
*
|
|
9
|
+
* IMPORTANT: checkAndIncrement is intentionally written as ONE synchronous
|
|
10
|
+
* block of work (wrapped in a resolved Promise) rather than two separate
|
|
11
|
+
* awaited steps. That is the actual fix for the race condition: if "read
|
|
12
|
+
* the count" and "increment the count" are two separate `await` points,
|
|
13
|
+
* the Node.js event loop can interleave other callers' code between them,
|
|
14
|
+
* letting more than `max` requests through under concurrent load. Doing
|
|
15
|
+
* both in one synchronous tick removes that interleaving opportunity here,
|
|
16
|
+
* the same way a Lua script removes it in Redis (Lua scripts run to
|
|
17
|
+
* completion without other Redis commands interleaving).
|
|
18
|
+
*/
|
|
19
|
+
class MemoryStore {
|
|
20
|
+
constructor() {
|
|
21
|
+
/** @type {Map<string, number>} windowKey -> count */
|
|
22
|
+
this.counts = new Map();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_windowKey(key, windowId) {
|
|
26
|
+
return `${key}:${windowId}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} args
|
|
31
|
+
* @param {string} args.key
|
|
32
|
+
* @param {number} args.currWindowId
|
|
33
|
+
* @param {number} args.prevWindowId
|
|
34
|
+
* @param {number} args.prevWeight
|
|
35
|
+
* @param {number} args.max
|
|
36
|
+
* @returns {Promise<{ allowed: boolean, weightedCount: number }>}
|
|
37
|
+
*/
|
|
38
|
+
async checkAndIncrement({ key, currWindowId, prevWindowId, prevWeight, max }) {
|
|
39
|
+
// Everything below is synchronous JS with no `await` in the middle —
|
|
40
|
+
// that's what makes this one atomic step from the event loop's
|
|
41
|
+
// perspective. No other checkAndIncrement call can interleave here.
|
|
42
|
+
const currCount = this.counts.get(this._windowKey(key, currWindowId)) || 0;
|
|
43
|
+
const prevCount = this.counts.get(this._windowKey(key, prevWindowId)) || 0;
|
|
44
|
+
|
|
45
|
+
const weightedCount = currCount + prevCount * prevWeight;
|
|
46
|
+
|
|
47
|
+
if (weightedCount >= max) {
|
|
48
|
+
return { allowed: false, weightedCount };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const newCurrCount = currCount + 1;
|
|
52
|
+
this.counts.set(this._windowKey(key, currWindowId), newCurrCount);
|
|
53
|
+
|
|
54
|
+
// opportunistically clean up windows that can no longer be referenced
|
|
55
|
+
// (anything older than the previous window is dead weight)
|
|
56
|
+
this.counts.delete(this._windowKey(key, prevWindowId - 1));
|
|
57
|
+
|
|
58
|
+
return { allowed: true, weightedCount: currCount + 1 + prevCount * prevWeight };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Test helper: wipe all state between test cases */
|
|
62
|
+
reset() {
|
|
63
|
+
this.counts.clear();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { MemoryStore };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const SCRIPT_PATH = path.join(__dirname, 'check_and_increment.lua');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* RedisStore implements the same checkAndIncrement(...) contract as
|
|
10
|
+
* MemoryStore, but backed by a real Redis instance via a Lua script
|
|
11
|
+
* (see check_and_increment.lua). This is what makes rate limiting
|
|
12
|
+
* correct across MULTIPLE app instances/processes sharing one Redis —
|
|
13
|
+
* the in-memory store only protects a single process.
|
|
14
|
+
*
|
|
15
|
+
* `ioredis` is a peer dependency, not bundled — pass in your own client
|
|
16
|
+
* instance so you control connection options, TLS, cluster mode, etc.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* const Redis = require('ioredis');
|
|
20
|
+
* const { RedisStore } = require('ratewall');
|
|
21
|
+
* const redis = new Redis(process.env.REDIS_URL);
|
|
22
|
+
* const store = new RedisStore({ redis });
|
|
23
|
+
*/
|
|
24
|
+
class RedisStore {
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} opts
|
|
27
|
+
* @param {object} opts.redis - an ioredis client instance
|
|
28
|
+
* @param {string} [opts.prefix] - key namespace prefix, default "rw"
|
|
29
|
+
*/
|
|
30
|
+
constructor({ redis, prefix = 'rw' }) {
|
|
31
|
+
if (!redis || typeof redis.defineCommand !== 'function') {
|
|
32
|
+
throw new Error('RedisStore requires an ioredis client instance (with defineCommand support)');
|
|
33
|
+
}
|
|
34
|
+
this.redis = redis;
|
|
35
|
+
this.prefix = prefix;
|
|
36
|
+
this._scriptLoaded = false;
|
|
37
|
+
this._loadScript();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_loadScript() {
|
|
41
|
+
if (this._scriptLoaded) return;
|
|
42
|
+
const luaSource = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
43
|
+
// defineCommand registers the script once and lets ioredis call it
|
|
44
|
+
// by name afterwards; ioredis handles EVALSHA caching + fallback to
|
|
45
|
+
// EVAL on a NOSCRIPT error internally, so we don't have to.
|
|
46
|
+
this.redis.defineCommand('rwCheckAndIncrement', {
|
|
47
|
+
numberOfKeys: 2,
|
|
48
|
+
lua: luaSource,
|
|
49
|
+
});
|
|
50
|
+
this._scriptLoaded = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_key(key, windowId) {
|
|
54
|
+
return `${this.prefix}:${key}:${windowId}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {object} args
|
|
59
|
+
* @param {string} args.key
|
|
60
|
+
* @param {number} args.currWindowId
|
|
61
|
+
* @param {number} args.prevWindowId
|
|
62
|
+
* @param {number} args.prevWeight
|
|
63
|
+
* @param {number} args.max
|
|
64
|
+
* @param {number} [args.windowMs] - needed for TTL; falls back to a
|
|
65
|
+
* generous default if not supplied by the caller.
|
|
66
|
+
* @returns {Promise<{ allowed: boolean, weightedCount: number }>}
|
|
67
|
+
*/
|
|
68
|
+
async checkAndIncrement({ key, currWindowId, prevWindowId, prevWeight, max, windowMs = 60_000 }) {
|
|
69
|
+
const currKey = this._key(key, currWindowId);
|
|
70
|
+
const prevKey = this._key(key, prevWindowId);
|
|
71
|
+
|
|
72
|
+
const [allowedFlag, scaledWeightedCount] = await this.redis.rwCheckAndIncrement(
|
|
73
|
+
currKey,
|
|
74
|
+
prevKey,
|
|
75
|
+
prevWeight,
|
|
76
|
+
max,
|
|
77
|
+
windowMs
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
allowed: allowedFlag === 1,
|
|
82
|
+
weightedCount: scaledWeightedCount / 1000,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { RedisStore };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SlidingWindowCounter implements the "sliding window counter" rate-limiting
|
|
5
|
+
* algorithm. It approximates a true sliding log by keeping two adjacent
|
|
6
|
+
* fixed windows (the current one and the previous one) and weighting the
|
|
7
|
+
* previous window's count by how much of it still overlaps the sliding
|
|
8
|
+
* window of size `windowMs` ending "now".
|
|
9
|
+
*
|
|
10
|
+
* Why not a fixed window?
|
|
11
|
+
* A fixed window resets its counter at a hard boundary (e.g. every
|
|
12
|
+
* 60s). A client can send `max` requests in the last millisecond of one
|
|
13
|
+
* window and another `max` in the first millisecond of the next,
|
|
14
|
+
* producing a burst of up to 2x `max` in a tiny span of real time. The
|
|
15
|
+
* sliding window counter removes most of that burst risk by carrying
|
|
16
|
+
* forward a weighted fraction of the previous window's count.
|
|
17
|
+
*
|
|
18
|
+
* Why not a true sliding window log?
|
|
19
|
+
* A sliding log (storing a timestamp per request) is exact, but costs
|
|
20
|
+
* O(n) storage per key and O(n) work per check, where n is the number
|
|
21
|
+
* of requests in the window. That's expensive at scale. The counter
|
|
22
|
+
* approach is O(1) storage and O(1) work per check, at the cost of a
|
|
23
|
+
* small approximation error right at window boundaries (see tests).
|
|
24
|
+
*
|
|
25
|
+
* Concurrency note:
|
|
26
|
+
* The store's `checkAndIncrement` must be a SINGLE atomic operation
|
|
27
|
+
* (read current+previous window counts AND increment in one step).
|
|
28
|
+
* If "check" and "increment" are two separate awaited steps, concurrent
|
|
29
|
+
* callers can race: multiple requests can all read count=0 before any
|
|
30
|
+
* of them commits an increment, letting more than `max` requests through.
|
|
31
|
+
* This is exactly why the Redis implementation uses a Lua script — Redis
|
|
32
|
+
* runs Lua scripts atomically, with no other command interleaving.
|
|
33
|
+
*/
|
|
34
|
+
class SlidingWindowCounter {
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {number} opts.windowMs - size of the sliding window, in ms
|
|
38
|
+
* @param {number} opts.max - max requests allowed per window
|
|
39
|
+
* @param {object} opts.store - object exposing async checkAndIncrement(key, currWindowId, prevWindowId, weight, max) -> { allowed, count }
|
|
40
|
+
*/
|
|
41
|
+
constructor({ windowMs, max, store }) {
|
|
42
|
+
if (!windowMs || windowMs <= 0) {
|
|
43
|
+
throw new Error('windowMs must be a positive number');
|
|
44
|
+
}
|
|
45
|
+
if (!max || max <= 0) {
|
|
46
|
+
throw new Error('max must be a positive number');
|
|
47
|
+
}
|
|
48
|
+
if (!store || typeof store.checkAndIncrement !== 'function') {
|
|
49
|
+
throw new Error('store must implement an async checkAndIncrement(...) method');
|
|
50
|
+
}
|
|
51
|
+
this.windowMs = windowMs;
|
|
52
|
+
this.max = max;
|
|
53
|
+
this.store = store;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} key - identifier for the caller (IP, user id, API key, etc.)
|
|
58
|
+
* @param {number} [now] - current timestamp in ms, injectable for tests
|
|
59
|
+
* @returns {Promise<{ allowed: boolean, count: number, remaining: number, resetMs: number }>}
|
|
60
|
+
*/
|
|
61
|
+
async check(key, now = Date.now()) {
|
|
62
|
+
const currWindowId = Math.floor(now / this.windowMs);
|
|
63
|
+
const prevWindowId = currWindowId - 1;
|
|
64
|
+
const elapsedInCurrent = now - currWindowId * this.windowMs;
|
|
65
|
+
const elapsedFraction = elapsedInCurrent / this.windowMs;
|
|
66
|
+
// weight given to the PREVIOUS window's count, shrinking linearly
|
|
67
|
+
// toward 0 as we move further into the current window.
|
|
68
|
+
const prevWeight = 1 - elapsedFraction;
|
|
69
|
+
|
|
70
|
+
const result = await this.store.checkAndIncrement({
|
|
71
|
+
key,
|
|
72
|
+
currWindowId,
|
|
73
|
+
prevWindowId,
|
|
74
|
+
prevWeight,
|
|
75
|
+
max: this.max,
|
|
76
|
+
windowMs: this.windowMs,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const resetMs = (currWindowId + 1) * this.windowMs - now;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
allowed: result.allowed,
|
|
83
|
+
count: result.weightedCount,
|
|
84
|
+
remaining: Math.max(0, this.max - result.weightedCount),
|
|
85
|
+
resetMs,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { SlidingWindowCounter };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* REAL Express + supertest integration tests.
|
|
5
|
+
*
|
|
6
|
+
* These are not run as part of this sandbox's test suite (express/supertest
|
|
7
|
+
* couldn't be installed here — no registry access). Run these on your own
|
|
8
|
+
* machine after `npm install`, to prove the middleware behaves correctly
|
|
9
|
+
* inside an actual Express app and HTTP request/response cycle, not just
|
|
10
|
+
* against the hand-rolled fake req/res used in express-middleware.test.js.
|
|
11
|
+
*
|
|
12
|
+
* npm install
|
|
13
|
+
* node --test test/express-integration.test.js
|
|
14
|
+
*/
|
|
15
|
+
const { test } = require('node:test');
|
|
16
|
+
const assert = require('node:assert/strict');
|
|
17
|
+
const express = require('express');
|
|
18
|
+
const request = require('supertest');
|
|
19
|
+
const { ratewall } = require('../src/express-middleware');
|
|
20
|
+
const { MemoryStore } = require('../src/memory-store');
|
|
21
|
+
|
|
22
|
+
function buildApp({ windowMs, max, store }) {
|
|
23
|
+
const app = express();
|
|
24
|
+
app.use(ratewall({ windowMs, max, store }));
|
|
25
|
+
app.get('/', (req, res) => res.status(200).json({ ok: true }));
|
|
26
|
+
return app;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('a real Express app allows requests under the limit', async () => {
|
|
30
|
+
const app = buildApp({ windowMs: 1000, max: 3, store: new MemoryStore() });
|
|
31
|
+
|
|
32
|
+
const res = await request(app).get('/');
|
|
33
|
+
assert.equal(res.status, 200);
|
|
34
|
+
assert.equal(res.body.ok, true);
|
|
35
|
+
assert.equal(res.headers['ratelimit-limit'], '3');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a real Express app returns 429 once the limit is exceeded', async () => {
|
|
39
|
+
const app = buildApp({ windowMs: 1000, max: 2, store: new MemoryStore() });
|
|
40
|
+
|
|
41
|
+
await request(app).get('/').expect(200);
|
|
42
|
+
await request(app).get('/').expect(200);
|
|
43
|
+
const blocked = await request(app).get('/');
|
|
44
|
+
|
|
45
|
+
assert.equal(blocked.status, 429);
|
|
46
|
+
assert.equal(blocked.body.error, 'Too Many Requests');
|
|
47
|
+
assert.ok(blocked.headers['retry-after']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('concurrent real HTTP requests do not exceed the limit', async () => {
|
|
51
|
+
const app = buildApp({ windowMs: 1000, max: 5, store: new MemoryStore() });
|
|
52
|
+
|
|
53
|
+
const requests = Array.from({ length: 15 }, () => request(app).get('/'));
|
|
54
|
+
const results = await Promise.all(requests);
|
|
55
|
+
|
|
56
|
+
const allowedCount = results.filter((r) => r.status === 200).length;
|
|
57
|
+
assert.equal(allowedCount, 5, 'exactly max(5) real HTTP requests should succeed under concurrent load');
|
|
58
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { ratewall, ipKeyGenerator } = require('../src/express-middleware');
|
|
6
|
+
const { MemoryStore } = require('../src/memory-store');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* These tests exercise the middleware function directly against minimal
|
|
10
|
+
* fake req/res objects, rather than a real Express app — real `express`
|
|
11
|
+
* isn't installable in this sandbox (no registry access). The fake req/res
|
|
12
|
+
* below implement exactly the surface the middleware actually touches
|
|
13
|
+
* (req.ip, res.setHeader, res.status().json()), so this proves the
|
|
14
|
+
* middleware's own logic is correct. It does NOT prove Express itself
|
|
15
|
+
* wires req.ip / trust proxy the way assumed here — that needs a real
|
|
16
|
+
* Express app, ideally with supertest, run locally (see README).
|
|
17
|
+
*/
|
|
18
|
+
function makeReq(ip = '127.0.0.1') {
|
|
19
|
+
return { ip };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeRes() {
|
|
23
|
+
const res = {
|
|
24
|
+
headers: {},
|
|
25
|
+
statusCode: 200,
|
|
26
|
+
body: undefined,
|
|
27
|
+
setHeader(name, value) {
|
|
28
|
+
this.headers[name] = value;
|
|
29
|
+
},
|
|
30
|
+
status(code) {
|
|
31
|
+
this.statusCode = code;
|
|
32
|
+
return this;
|
|
33
|
+
},
|
|
34
|
+
json(payload) {
|
|
35
|
+
this.body = payload;
|
|
36
|
+
return this;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return res;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('allows requests under the limit and calls next()', async () => {
|
|
43
|
+
const middleware = ratewall({ windowMs: 1000, max: 3, store: new MemoryStore() });
|
|
44
|
+
const req = makeReq('1.1.1.1');
|
|
45
|
+
const res = makeRes();
|
|
46
|
+
let nextCalled = false;
|
|
47
|
+
|
|
48
|
+
await middleware(req, res, () => {
|
|
49
|
+
nextCalled = true;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.equal(nextCalled, true);
|
|
53
|
+
assert.equal(res.statusCode, 200, 'should not have set an error status');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('blocks requests over the limit with a 429 and Retry-After header', async () => {
|
|
57
|
+
const middleware = ratewall({ windowMs: 1000, max: 1, store: new MemoryStore() });
|
|
58
|
+
const req = makeReq('2.2.2.2');
|
|
59
|
+
|
|
60
|
+
// first request: allowed
|
|
61
|
+
const res1 = makeRes();
|
|
62
|
+
await middleware(req, res1, () => {});
|
|
63
|
+
assert.equal(res1.statusCode, 200);
|
|
64
|
+
|
|
65
|
+
// second request, same key, same window: blocked
|
|
66
|
+
const res2 = makeRes();
|
|
67
|
+
let nextCalled = false;
|
|
68
|
+
await middleware(req, res2, () => {
|
|
69
|
+
nextCalled = true;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
assert.equal(nextCalled, false, 'next() should not be called when blocked');
|
|
73
|
+
assert.equal(res2.statusCode, 429);
|
|
74
|
+
assert.equal(res2.body.error, 'Too Many Requests');
|
|
75
|
+
assert.ok('Retry-After' in res2.headers);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('sets standard RateLimit-* headers by default', async () => {
|
|
79
|
+
const middleware = ratewall({ windowMs: 1000, max: 5, store: new MemoryStore() });
|
|
80
|
+
const req = makeReq('3.3.3.3');
|
|
81
|
+
const res = makeRes();
|
|
82
|
+
|
|
83
|
+
await middleware(req, res, () => {});
|
|
84
|
+
|
|
85
|
+
assert.equal(res.headers['RateLimit-Limit'], '5');
|
|
86
|
+
assert.equal(res.headers['RateLimit-Remaining'], '4');
|
|
87
|
+
assert.ok('RateLimit-Reset' in res.headers);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('omits standard headers when standardHeaders is false', async () => {
|
|
91
|
+
const middleware = ratewall({ windowMs: 1000, max: 5, store: new MemoryStore(), standardHeaders: false });
|
|
92
|
+
const req = makeReq('4.4.4.4');
|
|
93
|
+
const res = makeRes();
|
|
94
|
+
|
|
95
|
+
await middleware(req, res, () => {});
|
|
96
|
+
|
|
97
|
+
assert.equal('RateLimit-Limit' in res.headers, false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('different IPs are rate-limited independently via the default key generator', async () => {
|
|
101
|
+
const middleware = ratewall({ windowMs: 1000, max: 1, store: new MemoryStore() });
|
|
102
|
+
|
|
103
|
+
const resA1 = makeRes();
|
|
104
|
+
await middleware(makeReq('5.5.5.5'), resA1, () => {});
|
|
105
|
+
assert.equal(resA1.statusCode, 200);
|
|
106
|
+
|
|
107
|
+
const resB1 = makeRes();
|
|
108
|
+
await middleware(makeReq('6.6.6.6'), resB1, () => {});
|
|
109
|
+
assert.equal(resB1.statusCode, 200, 'a different IP should have its own independent budget');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('custom keyGenerator overrides the default per-IP behavior', async () => {
|
|
113
|
+
const middleware = ratewall({
|
|
114
|
+
windowMs: 1000,
|
|
115
|
+
max: 1,
|
|
116
|
+
store: new MemoryStore(),
|
|
117
|
+
keyGenerator: (req) => req.userId,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Same userId, different IPs -- should still share one budget, proving
|
|
121
|
+
// the custom keyGenerator is actually being used instead of req.ip.
|
|
122
|
+
const req1 = { ip: '7.7.7.7', userId: 'user-42' };
|
|
123
|
+
const req2 = { ip: '8.8.8.8', userId: 'user-42' };
|
|
124
|
+
|
|
125
|
+
const res1 = makeRes();
|
|
126
|
+
await middleware(req1, res1, () => {});
|
|
127
|
+
assert.equal(res1.statusCode, 200);
|
|
128
|
+
|
|
129
|
+
const res2 = makeRes();
|
|
130
|
+
let nextCalled = false;
|
|
131
|
+
await middleware(req2, res2, () => {
|
|
132
|
+
nextCalled = true;
|
|
133
|
+
});
|
|
134
|
+
assert.equal(nextCalled, false, 'should be blocked: same userId key, even though IP differs');
|
|
135
|
+
assert.equal(res2.statusCode, 429);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('custom handler is invoked instead of the default 429 response when blocked', async () => {
|
|
139
|
+
let handlerCalled = false;
|
|
140
|
+
const middleware = ratewall({
|
|
141
|
+
windowMs: 1000,
|
|
142
|
+
max: 1,
|
|
143
|
+
store: new MemoryStore(),
|
|
144
|
+
handler: (req, res) => {
|
|
145
|
+
handlerCalled = true;
|
|
146
|
+
res.status(503).json({ custom: true });
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const req = makeReq('9.9.9.9');
|
|
150
|
+
|
|
151
|
+
await middleware(req, makeRes(), () => {});
|
|
152
|
+
const res2 = makeRes();
|
|
153
|
+
await middleware(req, res2, () => {});
|
|
154
|
+
|
|
155
|
+
assert.equal(handlerCalled, true);
|
|
156
|
+
assert.equal(res2.statusCode, 503);
|
|
157
|
+
assert.equal(res2.body.custom, true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('a throwing store error calls next(err) instead of silently blocking (fail open)', async () => {
|
|
161
|
+
const brokenStore = {
|
|
162
|
+
async checkAndIncrement() {
|
|
163
|
+
throw new Error('redis connection lost');
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
const middleware = ratewall({ windowMs: 1000, max: 5, store: brokenStore });
|
|
167
|
+
const req = makeReq('10.10.10.10');
|
|
168
|
+
const res = makeRes();
|
|
169
|
+
let caughtErr = null;
|
|
170
|
+
|
|
171
|
+
await middleware(req, res, (err) => {
|
|
172
|
+
caughtErr = err;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
assert.ok(caughtErr instanceof Error);
|
|
176
|
+
assert.equal(caughtErr.message, 'redis connection lost');
|
|
177
|
+
assert.equal(res.statusCode, 200, 'should not have been treated as a 429 — fail open, not closed');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('ipKeyGenerator reads req.ip', () => {
|
|
181
|
+
assert.equal(ipKeyGenerator({ ip: '11.11.11.11' }), '11.11.11.11');
|
|
182
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { RedisStore } = require('../src/redis-store');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* FakeRedisClient mimics just enough of ioredis's shape (defineCommand +
|
|
9
|
+
* the resulting method call) to exercise RedisStore's logic without a
|
|
10
|
+
* real Redis server. It re-implements the Lua script's exact semantics
|
|
11
|
+
* in JS, so this test verifies RedisStore wires arguments correctly and
|
|
12
|
+
* interprets the script's return shape correctly — it does NOT verify
|
|
13
|
+
* that Redis itself runs the script atomically over the network. That
|
|
14
|
+
* claim can only be verified against a real Redis instance (see
|
|
15
|
+
* README's "Testing against real Redis" section) since it depends on
|
|
16
|
+
* Redis's actual single-threaded command execution guarantee, which a
|
|
17
|
+
* fake client can't reproduce.
|
|
18
|
+
*/
|
|
19
|
+
class FakeRedisClient {
|
|
20
|
+
constructor() {
|
|
21
|
+
this.store = new Map();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
defineCommand(name, { lua }) {
|
|
25
|
+
if (name !== 'rwCheckAndIncrement') {
|
|
26
|
+
throw new Error(`unexpected command name: ${name}`);
|
|
27
|
+
}
|
|
28
|
+
// confirm the real Lua source was actually read and passed in,
|
|
29
|
+
// rather than silently no-op'ing
|
|
30
|
+
if (!lua || !lua.includes('redis.call')) {
|
|
31
|
+
throw new Error('lua script source was not loaded correctly');
|
|
32
|
+
}
|
|
33
|
+
this.rwCheckAndIncrement = async (currKey, prevKey, prevWeight, max, windowMs) => {
|
|
34
|
+
const currCount = this.store.get(currKey) || 0;
|
|
35
|
+
const prevCount = this.store.get(prevKey) || 0;
|
|
36
|
+
const weightedCount = currCount + prevCount * prevWeight;
|
|
37
|
+
|
|
38
|
+
if (weightedCount >= max) {
|
|
39
|
+
return [0, Math.floor(weightedCount * 1000)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const newCurrCount = currCount + 1;
|
|
43
|
+
this.store.set(currKey, newCurrCount);
|
|
44
|
+
const finalWeightedCount = newCurrCount + prevCount * prevWeight;
|
|
45
|
+
return [1, Math.floor(finalWeightedCount * 1000)];
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('RedisStore throws clearly if not given an ioredis-shaped client', () => {
|
|
51
|
+
assert.throws(() => new RedisStore({ redis: {} }), /ioredis client instance/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('RedisStore registers the Lua script via defineCommand on construction', () => {
|
|
55
|
+
const fakeClient = new FakeRedisClient();
|
|
56
|
+
new RedisStore({ redis: fakeClient });
|
|
57
|
+
assert.equal(typeof fakeClient.rwCheckAndIncrement, 'function', 'script should be registered as a callable command');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('RedisStore allows requests under the limit and blocks over it', async () => {
|
|
61
|
+
const fakeClient = new FakeRedisClient();
|
|
62
|
+
const store = new RedisStore({ redis: fakeClient });
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < 5; i++) {
|
|
65
|
+
const result = await store.checkAndIncrement({
|
|
66
|
+
key: 'user-a',
|
|
67
|
+
currWindowId: 10,
|
|
68
|
+
prevWindowId: 9,
|
|
69
|
+
prevWeight: 0,
|
|
70
|
+
max: 5,
|
|
71
|
+
windowMs: 1000,
|
|
72
|
+
});
|
|
73
|
+
assert.equal(result.allowed, true, `request ${i + 1} should be allowed`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sixth = await store.checkAndIncrement({
|
|
77
|
+
key: 'user-a',
|
|
78
|
+
currWindowId: 10,
|
|
79
|
+
prevWindowId: 9,
|
|
80
|
+
prevWeight: 0,
|
|
81
|
+
max: 5,
|
|
82
|
+
windowMs: 1000,
|
|
83
|
+
});
|
|
84
|
+
assert.equal(sixth.allowed, false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('RedisStore namespaces keys with the configured prefix', async () => {
|
|
88
|
+
const fakeClient = new FakeRedisClient();
|
|
89
|
+
const store = new RedisStore({ redis: fakeClient, prefix: 'custom' });
|
|
90
|
+
|
|
91
|
+
await store.checkAndIncrement({
|
|
92
|
+
key: 'user-a',
|
|
93
|
+
currWindowId: 1,
|
|
94
|
+
prevWindowId: 0,
|
|
95
|
+
prevWeight: 0,
|
|
96
|
+
max: 5,
|
|
97
|
+
windowMs: 1000,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
assert.ok(fakeClient.store.has('custom:user-a:1'), 'key should be namespaced with custom prefix');
|
|
101
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test, beforeEach } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { SlidingWindowCounter } = require('../src/sliding-window-counter');
|
|
6
|
+
const { MemoryStore } = require('../src/memory-store');
|
|
7
|
+
|
|
8
|
+
let store;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
store = new MemoryStore();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('allows requests under the limit within a single window', async () => {
|
|
15
|
+
const limiter = new SlidingWindowCounter({ windowMs: 1000, max: 5, store });
|
|
16
|
+
const now = 10_000; // exactly at a window boundary, window 10
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < 5; i++) {
|
|
19
|
+
const result = await limiter.check('user-a', now + i);
|
|
20
|
+
assert.equal(result.allowed, true, `request ${i + 1} should be allowed`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sixth = await limiter.check('user-a', now + 5);
|
|
24
|
+
assert.equal(sixth.allowed, false, '6th request in the same window should be blocked');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('different keys are tracked independently', async () => {
|
|
28
|
+
const limiter = new SlidingWindowCounter({ windowMs: 1000, max: 2, store });
|
|
29
|
+
const now = 5000;
|
|
30
|
+
|
|
31
|
+
assert.equal((await limiter.check('user-a', now)).allowed, true);
|
|
32
|
+
assert.equal((await limiter.check('user-a', now)).allowed, true);
|
|
33
|
+
assert.equal((await limiter.check('user-a', now)).allowed, false);
|
|
34
|
+
|
|
35
|
+
// user-b has its own independent budget
|
|
36
|
+
assert.equal((await limiter.check('user-b', now)).allowed, true);
|
|
37
|
+
assert.equal((await limiter.check('user-b', now)).allowed, true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('count resets once the previous window fully decays out of the sliding range', async () => {
|
|
41
|
+
const limiter = new SlidingWindowCounter({ windowMs: 1000, max: 5, store });
|
|
42
|
+
|
|
43
|
+
// fill window 10 completely (now = 10_500, mid-window)
|
|
44
|
+
for (let i = 0; i < 5; i++) {
|
|
45
|
+
await limiter.check('user-a', 10_500);
|
|
46
|
+
}
|
|
47
|
+
assert.equal((await limiter.check('user-a', 10_500)).allowed, false);
|
|
48
|
+
|
|
49
|
+
// jump forward two full windows — window 10's count should no longer
|
|
50
|
+
// weigh on window 12 at all (prevWindowId for window 12 is window 11,
|
|
51
|
+
// which has 0 requests).
|
|
52
|
+
const farFuture = await limiter.check('user-a', 12_500);
|
|
53
|
+
assert.equal(farFuture.allowed, true, 'budget should be fully available 2 windows later');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('boundary burst: requests right at a window edge are still capped near the true limit (not doubled)', async () => {
|
|
57
|
+
const limiter = new SlidingWindowCounter({ windowMs: 1000, max: 10, store });
|
|
58
|
+
|
|
59
|
+
// Fill window 10 right at its very end: now = 10_990 -> window 10,
|
|
60
|
+
// elapsedFraction = 990/1000 = 0.99
|
|
61
|
+
for (let i = 0; i < 10; i++) {
|
|
62
|
+
const r = await limiter.check('user-a', 10_990);
|
|
63
|
+
assert.equal(r.allowed, true);
|
|
64
|
+
}
|
|
65
|
+
assert.equal((await limiter.check('user-a', 10_990)).allowed, false);
|
|
66
|
+
|
|
67
|
+
// Now check at the very start of the NEXT window: now = 11_010 ->
|
|
68
|
+
// window 11, elapsedFraction = 10/1000 = 0.01, so prevWeight = 0.99.
|
|
69
|
+
// weightedCount going in = 0 (curr) + 10 * 0.99 (prev) = 9.9, which is
|
|
70
|
+
// just under max(10) -- so exactly ONE more request is allowed before
|
|
71
|
+
// it's blocked again. A fixed window, by contrast, would allow a full
|
|
72
|
+
// fresh batch of 10 here, doubling the effective burst to 20 in ~20ms.
|
|
73
|
+
// The sliding window counter's small approximation margin (allowing
|
|
74
|
+
// this one extra request) is the documented tradeoff against a true
|
|
75
|
+
// sliding log, traded for O(1) storage/work per check.
|
|
76
|
+
const firstInNextWindow = await limiter.check('user-a', 11_010);
|
|
77
|
+
assert.equal(firstInNextWindow.allowed, true, 'just under the weighted limit, correctly allowed');
|
|
78
|
+
|
|
79
|
+
const secondInNextWindow = await limiter.check('user-a', 11_011);
|
|
80
|
+
assert.equal(secondInNextWindow.allowed, false, 'should now be blocked — burst capped near the limit, not doubled');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('concurrent requests do not exceed the limit (no check-then-act race)', async () => {
|
|
84
|
+
const limiter = new SlidingWindowCounter({ windowMs: 1000, max: 5, store });
|
|
85
|
+
const now = 20_000;
|
|
86
|
+
|
|
87
|
+
// Fire 10 concurrent checks against a budget of 5. If checkAndIncrement
|
|
88
|
+
// were two separate awaited steps (read, then write), the event loop
|
|
89
|
+
// could interleave all 10 reads before any write commits, letting all
|
|
90
|
+
// 10 through. With one atomic step, exactly 5 should be allowed.
|
|
91
|
+
const results = await Promise.all(
|
|
92
|
+
Array.from({ length: 10 }, () => limiter.check('user-a', now))
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const allowedCount = results.filter((r) => r.allowed).length;
|
|
96
|
+
assert.equal(allowedCount, 5, 'exactly max(5) requests should be allowed under concurrent load');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('remaining and resetMs are reported sensibly', async () => {
|
|
100
|
+
const limiter = new SlidingWindowCounter({ windowMs: 1000, max: 5, store });
|
|
101
|
+
const now = 30_200; // 200ms into window 30
|
|
102
|
+
|
|
103
|
+
const result = await limiter.check('user-a', now);
|
|
104
|
+
assert.equal(result.allowed, true);
|
|
105
|
+
assert.equal(result.remaining, 4);
|
|
106
|
+
assert.equal(result.resetMs, 800); // 1000ms window, 200ms elapsed -> 800ms left
|
|
107
|
+
});
|