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 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
+ });