@superfunctions/middleware 0.1.0 → 0.2.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/dist/rate-limit.d.ts +18 -1
- package/dist/rate-limit.js +230 -80
- package/dist/rate-limit.test.js +150 -0
- package/package.json +6 -3
package/dist/rate-limit.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Adapter, type AtomicKVStoreAdapter, type KVStoreAdapter } from '@superfunctions/db';
|
|
2
|
+
import { type ObservabilityInput } from '@superfunctions/observability';
|
|
2
3
|
export { KVStoreAdapter };
|
|
3
4
|
export type LegacyWindowAlgorithm = 'sliding-window' | 'fixed-window';
|
|
4
5
|
export type RateLimitAlgorithm = LegacyWindowAlgorithm | 'token-bucket';
|
|
@@ -7,8 +8,11 @@ export interface RateLimitConfig {
|
|
|
7
8
|
maxRequests: number;
|
|
8
9
|
keyPrefix?: string;
|
|
9
10
|
persistence?: Adapter | KVStoreAdapter;
|
|
11
|
+
atomicStore?: AtomicKVStoreAdapter;
|
|
10
12
|
algorithm?: RateLimitAlgorithm;
|
|
11
13
|
now?: () => number;
|
|
14
|
+
observability?: ObservabilityInput;
|
|
15
|
+
component?: string;
|
|
12
16
|
}
|
|
13
17
|
export interface RateLimitResult {
|
|
14
18
|
allowed: boolean;
|
|
@@ -21,8 +25,21 @@ export interface CheckLimitInput {
|
|
|
21
25
|
windowSeconds?: number;
|
|
22
26
|
limit?: number;
|
|
23
27
|
}
|
|
28
|
+
export interface CheckManyLimitsInput {
|
|
29
|
+
keys: string[];
|
|
30
|
+
windowSeconds?: number;
|
|
31
|
+
limit?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface RateLimitManyResult {
|
|
34
|
+
allowed: boolean;
|
|
35
|
+
remainingByKey: Map<string, number>;
|
|
36
|
+
resetAtByKey: Map<string, string>;
|
|
37
|
+
resetAt: string;
|
|
38
|
+
total: number;
|
|
39
|
+
}
|
|
24
40
|
export interface RateLimiter {
|
|
25
41
|
check(input: CheckLimitInput): Promise<RateLimitResult>;
|
|
42
|
+
checkMany(input: CheckManyLimitsInput): Promise<RateLimitManyResult>;
|
|
26
43
|
reset(key: string): Promise<void>;
|
|
27
44
|
}
|
|
28
45
|
declare function createInMemoryKVStore(now?: () => number): KVStoreAdapter;
|
package/dist/rate-limit.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { instrumentKVStore } from '@superfunctions/db';
|
|
2
|
+
import { normalizeObservability } from '@superfunctions/observability';
|
|
1
3
|
function createInMemoryKVStore(now = Date.now) {
|
|
2
4
|
const store = new Map();
|
|
3
5
|
return {
|
|
@@ -59,8 +61,7 @@ function ensureKV(p, now) {
|
|
|
59
61
|
}
|
|
60
62
|
return p;
|
|
61
63
|
}
|
|
62
|
-
|
|
63
|
-
const data = await kv.get(key);
|
|
64
|
+
function parseState(data) {
|
|
64
65
|
if (!data)
|
|
65
66
|
return null;
|
|
66
67
|
try {
|
|
@@ -70,20 +71,25 @@ async function getState(kv, key) {
|
|
|
70
71
|
return null;
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
|
-
async function setState(kv, key, state, ttlMs) {
|
|
74
|
-
if (ttlMs <= 0) {
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
await kv.set({
|
|
78
|
-
key,
|
|
79
|
-
value: JSON.stringify(state),
|
|
80
|
-
ttlSeconds: Math.ceil(ttlMs / 1000),
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
74
|
export function createRateLimiter(config) {
|
|
84
75
|
const now = config.now ?? Date.now;
|
|
85
|
-
const { windowMs, maxRequests, keyPrefix = 'ratelimit:', persistence = createInMemoryKVStore(now), algorithm = 'fixed-window', } = config;
|
|
86
|
-
const
|
|
76
|
+
const { windowMs, maxRequests, keyPrefix = 'ratelimit:', persistence = createInMemoryKVStore(now), atomicStore, algorithm = 'fixed-window', observability: observabilityInput, component = 'rate-limit', } = config;
|
|
77
|
+
const observability = normalizeObservability(observabilityInput)?.child({ component });
|
|
78
|
+
if (atomicStore && typeof atomicStore.compareAndSet !== 'function') {
|
|
79
|
+
throw new Error('RATE_LIMIT_ATOMIC_CAS_REQUIRED');
|
|
80
|
+
}
|
|
81
|
+
const atomic = atomicStore
|
|
82
|
+
? instrumentKVStore(atomicStore, {
|
|
83
|
+
observability,
|
|
84
|
+
kind: 'cache',
|
|
85
|
+
component: `${component}.atomic`,
|
|
86
|
+
})
|
|
87
|
+
: undefined;
|
|
88
|
+
const kv = instrumentKVStore(ensureKV(persistence, now), {
|
|
89
|
+
observability,
|
|
90
|
+
kind: 'cache',
|
|
91
|
+
component: `${component}.cache`,
|
|
92
|
+
});
|
|
87
93
|
const keyLocks = new Map();
|
|
88
94
|
async function withKeyLock(key, work) {
|
|
89
95
|
const prior = keyLocks.get(key) ?? Promise.resolve();
|
|
@@ -104,89 +110,233 @@ export function createRateLimiter(config) {
|
|
|
104
110
|
}
|
|
105
111
|
}
|
|
106
112
|
}
|
|
113
|
+
async function withKeyLocks(keys, work) {
|
|
114
|
+
const uniqueKeys = [...new Set(keys)].sort();
|
|
115
|
+
const releases = [];
|
|
116
|
+
const queuedLocks = [];
|
|
117
|
+
try {
|
|
118
|
+
for (const key of uniqueKeys) {
|
|
119
|
+
const prior = keyLocks.get(key) ?? Promise.resolve();
|
|
120
|
+
let resolveLock;
|
|
121
|
+
const current = new Promise((resolve) => {
|
|
122
|
+
resolveLock = resolve;
|
|
123
|
+
});
|
|
124
|
+
const queued = prior.then(() => current);
|
|
125
|
+
keyLocks.set(key, queued);
|
|
126
|
+
queuedLocks.push({ key, queued });
|
|
127
|
+
releases.push(resolveLock);
|
|
128
|
+
await prior;
|
|
129
|
+
}
|
|
130
|
+
return await work();
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
for (let index = releases.length - 1; index >= 0; index -= 1) {
|
|
134
|
+
releases[index]();
|
|
135
|
+
}
|
|
136
|
+
for (const { key, queued } of queuedLocks) {
|
|
137
|
+
if (keyLocks.get(key) === queued) {
|
|
138
|
+
keyLocks.delete(key);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function evaluateState(rawState, currentTime, effectiveWindowMs, effectiveLimit, commit) {
|
|
144
|
+
if (!Number.isFinite(effectiveWindowMs) || effectiveWindowMs <= 0) {
|
|
145
|
+
throw new Error('RATE_LIMIT_WINDOW_INVALID');
|
|
146
|
+
}
|
|
147
|
+
if (!Number.isFinite(effectiveLimit) || effectiveLimit < 0) {
|
|
148
|
+
throw new Error('RATE_LIMIT_LIMIT_INVALID');
|
|
149
|
+
}
|
|
150
|
+
if (algorithm === 'sliding-window') {
|
|
151
|
+
const state = parseState(rawState) ?? { timestamps: [] };
|
|
152
|
+
const floor = currentTime - effectiveWindowMs;
|
|
153
|
+
const timestamps = state.timestamps.filter((timestamp) => timestamp > floor);
|
|
154
|
+
const allowed = timestamps.length < effectiveLimit;
|
|
155
|
+
if (allowed && commit) {
|
|
156
|
+
timestamps.push(currentTime);
|
|
157
|
+
}
|
|
158
|
+
const oldest = timestamps[0] ?? currentTime;
|
|
159
|
+
const resetAt = oldest + effectiveWindowMs;
|
|
160
|
+
return {
|
|
161
|
+
result: {
|
|
162
|
+
allowed,
|
|
163
|
+
remaining: Math.max(0, effectiveLimit - timestamps.length),
|
|
164
|
+
resetAt: new Date(resetAt).toISOString(),
|
|
165
|
+
total: effectiveLimit,
|
|
166
|
+
},
|
|
167
|
+
...(commit
|
|
168
|
+
? {
|
|
169
|
+
nextState: { timestamps, expiresAt: resetAt },
|
|
170
|
+
ttlMs: Math.max(1, resetAt - currentTime),
|
|
171
|
+
}
|
|
172
|
+
: {}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (algorithm === 'token-bucket') {
|
|
176
|
+
if (effectiveLimit <= 0) {
|
|
177
|
+
const resetAt = currentTime + Math.max(0, effectiveWindowMs);
|
|
178
|
+
return {
|
|
179
|
+
result: {
|
|
180
|
+
allowed: false,
|
|
181
|
+
remaining: 0,
|
|
182
|
+
resetAt: new Date(resetAt).toISOString(),
|
|
183
|
+
total: effectiveLimit,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const refillRatePerMs = effectiveLimit / effectiveWindowMs;
|
|
188
|
+
const state = parseState(rawState) ?? {
|
|
189
|
+
tokens: effectiveLimit,
|
|
190
|
+
lastRefill: currentTime,
|
|
191
|
+
};
|
|
192
|
+
const elapsed = Math.max(0, currentTime - state.lastRefill);
|
|
193
|
+
const tokens = Math.min(effectiveLimit, state.tokens + elapsed * refillRatePerMs);
|
|
194
|
+
const allowed = tokens >= 1;
|
|
195
|
+
const nextTokens = allowed && commit ? tokens - 1 : tokens;
|
|
196
|
+
const msUntilNextToken = nextTokens >= 1 ? 0 : Math.ceil((1 - nextTokens) / refillRatePerMs);
|
|
197
|
+
const msUntilFull = Math.ceil((effectiveLimit - nextTokens) / refillRatePerMs);
|
|
198
|
+
const resetAt = currentTime + msUntilNextToken;
|
|
199
|
+
return {
|
|
200
|
+
result: {
|
|
201
|
+
allowed,
|
|
202
|
+
remaining: Math.max(0, Math.floor(nextTokens)),
|
|
203
|
+
resetAt: new Date(resetAt).toISOString(),
|
|
204
|
+
total: effectiveLimit,
|
|
205
|
+
},
|
|
206
|
+
...(commit
|
|
207
|
+
? {
|
|
208
|
+
nextState: {
|
|
209
|
+
tokens: nextTokens,
|
|
210
|
+
lastRefill: currentTime,
|
|
211
|
+
expiresAt: currentTime + Math.max(1, msUntilFull),
|
|
212
|
+
},
|
|
213
|
+
ttlMs: Math.max(1, msUntilFull),
|
|
214
|
+
}
|
|
215
|
+
: {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const state = parseState(rawState) ?? {
|
|
219
|
+
count: 0,
|
|
220
|
+
windowStart: currentTime,
|
|
221
|
+
};
|
|
222
|
+
const windowState = currentTime >= state.windowStart + effectiveWindowMs
|
|
223
|
+
? { count: 0, windowStart: currentTime }
|
|
224
|
+
: { ...state };
|
|
225
|
+
const allowed = windowState.count < effectiveLimit;
|
|
226
|
+
if (allowed && commit) {
|
|
227
|
+
windowState.count += 1;
|
|
228
|
+
}
|
|
229
|
+
const resetAt = windowState.windowStart + effectiveWindowMs;
|
|
230
|
+
return {
|
|
231
|
+
result: {
|
|
232
|
+
allowed,
|
|
233
|
+
remaining: Math.max(0, effectiveLimit - windowState.count),
|
|
234
|
+
resetAt: new Date(resetAt).toISOString(),
|
|
235
|
+
total: effectiveLimit,
|
|
236
|
+
},
|
|
237
|
+
...(commit
|
|
238
|
+
? {
|
|
239
|
+
nextState: { ...windowState, expiresAt: resetAt },
|
|
240
|
+
ttlMs: Math.max(1, resetAt - currentTime),
|
|
241
|
+
}
|
|
242
|
+
: {}),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async function evaluateKey(key, currentTime, effectiveWindowMs, effectiveLimit, commit) {
|
|
246
|
+
const evaluation = evaluateState(await kv.get(key), currentTime, effectiveWindowMs, effectiveLimit, commit);
|
|
247
|
+
if (commit && evaluation.nextState !== undefined && evaluation.ttlMs !== undefined) {
|
|
248
|
+
await kv.set({
|
|
249
|
+
key,
|
|
250
|
+
value: JSON.stringify(evaluation.nextState),
|
|
251
|
+
ttlSeconds: Math.ceil(evaluation.ttlMs / 1000),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return evaluation.result;
|
|
255
|
+
}
|
|
256
|
+
async function evaluateAtomicKey(key, currentTime, effectiveWindowMs, effectiveLimit, commit) {
|
|
257
|
+
if (!atomic?.compareAndSet) {
|
|
258
|
+
throw new Error('RATE_LIMIT_ATOMIC_CAS_REQUIRED');
|
|
259
|
+
}
|
|
260
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
261
|
+
const expected = await atomic.get(key);
|
|
262
|
+
const evaluation = evaluateState(expected, currentTime, effectiveWindowMs, effectiveLimit, commit);
|
|
263
|
+
if (!commit || evaluation.nextState === undefined || evaluation.ttlMs === undefined) {
|
|
264
|
+
return evaluation.result;
|
|
265
|
+
}
|
|
266
|
+
const result = await atomic.compareAndSet({
|
|
267
|
+
key,
|
|
268
|
+
expected,
|
|
269
|
+
value: JSON.stringify(evaluation.nextState),
|
|
270
|
+
ttlSeconds: Math.ceil(evaluation.ttlMs / 1000),
|
|
271
|
+
});
|
|
272
|
+
if (result.updated) {
|
|
273
|
+
return evaluation.result;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
throw new Error('RATE_LIMIT_ATOMIC_CONTENTION');
|
|
277
|
+
}
|
|
278
|
+
const evaluateConfiguredKey = atomic ? evaluateAtomicKey : evaluateKey;
|
|
107
279
|
return {
|
|
108
280
|
async check(input) {
|
|
109
281
|
const key = `${keyPrefix}${input.key}`;
|
|
110
282
|
return withKeyLock(key, async () => {
|
|
111
283
|
const effectiveWindowMs = (input.windowSeconds !== undefined ? input.windowSeconds * 1000 : undefined) ?? windowMs;
|
|
112
284
|
const effectiveLimit = input.limit ?? maxRequests;
|
|
285
|
+
return evaluateConfiguredKey(key, now(), effectiveWindowMs, effectiveLimit, true);
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
async checkMany(input) {
|
|
289
|
+
const uniqueKeys = [...new Set(input.keys)];
|
|
290
|
+
const effectiveWindowMs = (input.windowSeconds !== undefined ? input.windowSeconds * 1000 : undefined) ?? windowMs;
|
|
291
|
+
const effectiveLimit = input.limit ?? maxRequests;
|
|
292
|
+
const namespacedKeys = uniqueKeys.map((key) => `${keyPrefix}${key}`);
|
|
293
|
+
if (uniqueKeys.length === 0) {
|
|
294
|
+
return {
|
|
295
|
+
allowed: true,
|
|
296
|
+
remainingByKey: new Map(),
|
|
297
|
+
resetAtByKey: new Map(),
|
|
298
|
+
resetAt: new Date(now()).toISOString(),
|
|
299
|
+
total: effectiveLimit,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return withKeyLocks(namespacedKeys, async () => {
|
|
113
303
|
const currentTime = now();
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (!Number.isFinite(effectiveLimit) || effectiveLimit < 0) {
|
|
118
|
-
throw new Error('RATE_LIMIT_LIMIT_INVALID');
|
|
119
|
-
}
|
|
120
|
-
if (algorithm === 'sliding-window') {
|
|
121
|
-
const state = (await getState(kv, key)) ?? { timestamps: [] };
|
|
122
|
-
const floor = currentTime - effectiveWindowMs;
|
|
123
|
-
state.timestamps = state.timestamps.filter((timestamp) => timestamp > floor);
|
|
124
|
-
const allowed = state.timestamps.length < effectiveLimit;
|
|
125
|
-
if (allowed) {
|
|
126
|
-
state.timestamps.push(currentTime);
|
|
127
|
-
}
|
|
128
|
-
const oldest = state.timestamps[0] ?? currentTime;
|
|
129
|
-
const resetAt = oldest + effectiveWindowMs;
|
|
130
|
-
const ttlMs = Math.max(1, resetAt - currentTime);
|
|
131
|
-
await setState(kv, key, state, ttlMs);
|
|
304
|
+
const preflight = await Promise.all(namespacedKeys.map((key) => evaluateConfiguredKey(key, currentTime, effectiveWindowMs, effectiveLimit, false)));
|
|
305
|
+
const blocked = preflight.filter((result) => !result.allowed);
|
|
306
|
+
if (blocked.length > 0) {
|
|
132
307
|
return {
|
|
133
|
-
allowed,
|
|
134
|
-
|
|
135
|
-
|
|
308
|
+
allowed: false,
|
|
309
|
+
remainingByKey: new Map(uniqueKeys.map((key, index) => [key, preflight[index].remaining])),
|
|
310
|
+
resetAtByKey: new Map(uniqueKeys.map((key, index) => [key, preflight[index].resetAt])),
|
|
311
|
+
resetAt: new Date(Math.max(...blocked.map((result) => Date.parse(result.resetAt)))).toISOString(),
|
|
136
312
|
total: effectiveLimit,
|
|
137
313
|
};
|
|
138
314
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
315
|
+
const committed = [];
|
|
316
|
+
for (let index = 0; index < namespacedKeys.length; index += 1) {
|
|
317
|
+
const key = namespacedKeys[index];
|
|
318
|
+
// Commit each key through the configured store. If a later commit
|
|
319
|
+
// becomes blocked or fails, retain earlier quota charges (fail
|
|
320
|
+
// closed) instead of restoring stale snapshots over concurrent work.
|
|
321
|
+
const result = await evaluateConfiguredKey(key, currentTime, effectiveWindowMs, effectiveLimit, true);
|
|
322
|
+
committed.push(result);
|
|
323
|
+
if (!result.allowed) {
|
|
324
|
+
const actual = preflight.map((entry, entryIndex) => committed[entryIndex] ?? entry);
|
|
325
|
+
const blockedActual = actual.filter((entry) => !entry.allowed);
|
|
142
326
|
return {
|
|
143
327
|
allowed: false,
|
|
144
|
-
|
|
145
|
-
|
|
328
|
+
remainingByKey: new Map(uniqueKeys.map((entry, entryIndex) => [entry, actual[entryIndex].remaining])),
|
|
329
|
+
resetAtByKey: new Map(uniqueKeys.map((entry, entryIndex) => [entry, actual[entryIndex].resetAt])),
|
|
330
|
+
resetAt: new Date(Math.max(...blockedActual.map((entry) => Date.parse(entry.resetAt)))).toISOString(),
|
|
146
331
|
total: effectiveLimit,
|
|
147
332
|
};
|
|
148
333
|
}
|
|
149
|
-
const refillRatePerMs = effectiveLimit / effectiveWindowMs;
|
|
150
|
-
const state = (await getState(kv, key)) ?? {
|
|
151
|
-
tokens: effectiveLimit,
|
|
152
|
-
lastRefill: currentTime,
|
|
153
|
-
};
|
|
154
|
-
const elapsed = Math.max(0, currentTime - state.lastRefill);
|
|
155
|
-
state.tokens = Math.min(effectiveLimit, state.tokens + elapsed * refillRatePerMs);
|
|
156
|
-
state.lastRefill = currentTime;
|
|
157
|
-
const allowed = state.tokens >= 1;
|
|
158
|
-
if (allowed) {
|
|
159
|
-
state.tokens -= 1;
|
|
160
|
-
}
|
|
161
|
-
const msUntilNextToken = state.tokens >= 1 ? 0 : Math.ceil((1 - state.tokens) / refillRatePerMs);
|
|
162
|
-
const msUntilFull = Math.ceil((effectiveLimit - state.tokens) / refillRatePerMs);
|
|
163
|
-
const resetAt = currentTime + msUntilNextToken;
|
|
164
|
-
await setState(kv, key, state, Math.max(1, msUntilFull));
|
|
165
|
-
return {
|
|
166
|
-
allowed,
|
|
167
|
-
remaining: Math.max(0, Math.floor(state.tokens)),
|
|
168
|
-
resetAt: new Date(resetAt).toISOString(),
|
|
169
|
-
total: effectiveLimit,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
const state = (await getState(kv, key)) ?? {
|
|
173
|
-
count: 0,
|
|
174
|
-
windowStart: currentTime,
|
|
175
|
-
};
|
|
176
|
-
if (currentTime >= state.windowStart + effectiveWindowMs) {
|
|
177
|
-
state.count = 0;
|
|
178
|
-
state.windowStart = currentTime;
|
|
179
|
-
}
|
|
180
|
-
const allowed = state.count < effectiveLimit;
|
|
181
|
-
if (allowed) {
|
|
182
|
-
state.count += 1;
|
|
183
334
|
}
|
|
184
|
-
const resetAt = state.windowStart + effectiveWindowMs;
|
|
185
|
-
await setState(kv, key, state, Math.max(1, resetAt - currentTime));
|
|
186
335
|
return {
|
|
187
|
-
allowed,
|
|
188
|
-
|
|
189
|
-
|
|
336
|
+
allowed: true,
|
|
337
|
+
remainingByKey: new Map(uniqueKeys.map((key, index) => [key, committed[index].remaining])),
|
|
338
|
+
resetAtByKey: new Map(uniqueKeys.map((key, index) => [key, committed[index].resetAt])),
|
|
339
|
+
resetAt: new Date(Math.min(...committed.map((result) => Date.parse(result.resetAt)))).toISOString(),
|
|
190
340
|
total: effectiveLimit,
|
|
191
341
|
};
|
|
192
342
|
});
|
|
@@ -194,7 +344,7 @@ export function createRateLimiter(config) {
|
|
|
194
344
|
async reset(key) {
|
|
195
345
|
const namespacedKey = `${keyPrefix}${key}`;
|
|
196
346
|
await withKeyLock(namespacedKey, async () => {
|
|
197
|
-
await kv.delete(namespacedKey);
|
|
347
|
+
await (atomic ?? kv).delete(namespacedKey);
|
|
198
348
|
});
|
|
199
349
|
},
|
|
200
350
|
};
|
package/dist/rate-limit.test.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createObservability, readObservationGroup } from '@superfunctions/observability';
|
|
3
|
+
import { createMemoryAtomicKVStore } from '@superfunctions/db/adapters';
|
|
2
4
|
import { createRateLimiter, createInMemoryKVStore } from './rate-limit.js';
|
|
3
5
|
describe('rate-limit', () => {
|
|
4
6
|
beforeEach(() => {
|
|
@@ -109,6 +111,106 @@ describe('rate-limit', () => {
|
|
|
109
111
|
expect((await limiter.check({ key: 'user2' })).allowed).toBe(true);
|
|
110
112
|
expect((await limiter.check({ key: 'user1' })).allowed).toBe(false);
|
|
111
113
|
});
|
|
114
|
+
it('checks multiple keys without consuming partial capacity when one key is blocked', async () => {
|
|
115
|
+
const limiter = createRateLimiter({
|
|
116
|
+
windowMs: 60000,
|
|
117
|
+
maxRequests: 1,
|
|
118
|
+
algorithm: 'token-bucket',
|
|
119
|
+
});
|
|
120
|
+
expect((await limiter.check({ key: 'provider' })).allowed).toBe(true);
|
|
121
|
+
const multi = await limiter.checkMany({ keys: ['provider', 'tenant'] });
|
|
122
|
+
expect(multi.allowed).toBe(false);
|
|
123
|
+
expect(multi.remainingByKey.get('provider')).toBe(0);
|
|
124
|
+
expect(multi.remainingByKey.get('tenant')).toBe(1);
|
|
125
|
+
expect((await limiter.check({ key: 'tenant' })).allowed).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
it('uses the latest blocked reset time for multi-key denials', async () => {
|
|
128
|
+
vi.useFakeTimers();
|
|
129
|
+
vi.setSystemTime(new Date('2026-03-12T00:00:00.000Z'));
|
|
130
|
+
const limiter = createRateLimiter({
|
|
131
|
+
windowMs: 1000,
|
|
132
|
+
maxRequests: 1,
|
|
133
|
+
algorithm: 'token-bucket',
|
|
134
|
+
});
|
|
135
|
+
await limiter.check({ key: 'provider' });
|
|
136
|
+
vi.setSystemTime(new Date('2026-03-12T00:00:00.500Z'));
|
|
137
|
+
await limiter.check({ key: 'tenant' });
|
|
138
|
+
vi.setSystemTime(new Date('2026-03-12T00:00:00.600Z'));
|
|
139
|
+
const multi = await limiter.checkMany({ keys: ['provider', 'tenant'] });
|
|
140
|
+
expect(multi.allowed).toBe(false);
|
|
141
|
+
expect(multi.resetAt).toBe('2026-03-12T00:00:01.500Z');
|
|
142
|
+
expect(multi.resetAtByKey.get('provider')).toBe('2026-03-12T00:00:01.000Z');
|
|
143
|
+
expect(multi.resetAtByKey.get('tenant')).toBe('2026-03-12T00:00:01.500Z');
|
|
144
|
+
});
|
|
145
|
+
it('fails closed without overwriting concurrent quota when a later write fails', async () => {
|
|
146
|
+
const internalStore = new Map();
|
|
147
|
+
let failTenantWrite = true;
|
|
148
|
+
const customStore = {
|
|
149
|
+
async get(key) {
|
|
150
|
+
return internalStore.get(key) ?? null;
|
|
151
|
+
},
|
|
152
|
+
async set(input) {
|
|
153
|
+
if (input.key === 'ratelimit:tenant' && failTenantWrite) {
|
|
154
|
+
failTenantWrite = false;
|
|
155
|
+
internalStore.set('ratelimit:provider', JSON.stringify({ count: 2, windowStart: 0, expiresAt: 60000 }));
|
|
156
|
+
throw new Error('write failed');
|
|
157
|
+
}
|
|
158
|
+
internalStore.set(input.key, input.value);
|
|
159
|
+
},
|
|
160
|
+
async delete(key) {
|
|
161
|
+
internalStore.delete(key);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
const limiter = createRateLimiter({
|
|
165
|
+
windowMs: 60000,
|
|
166
|
+
maxRequests: 1,
|
|
167
|
+
algorithm: 'fixed-window',
|
|
168
|
+
persistence: customStore,
|
|
169
|
+
now: () => 0,
|
|
170
|
+
});
|
|
171
|
+
await expect(limiter.checkMany({ keys: ['provider', 'tenant'] })).rejects.toThrow('write failed');
|
|
172
|
+
expect(JSON.parse(internalStore.get('ratelimit:provider') ?? '{}')).toMatchObject({
|
|
173
|
+
count: 2,
|
|
174
|
+
});
|
|
175
|
+
expect((await limiter.check({ key: 'provider' })).allowed).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
it('retains prior quota charges after a partial commit failure', async () => {
|
|
178
|
+
const internalStore = new Map();
|
|
179
|
+
const ttlByKey = new Map();
|
|
180
|
+
let currentTime = 0;
|
|
181
|
+
const customStore = {
|
|
182
|
+
async get(key) {
|
|
183
|
+
return internalStore.get(key) ?? null;
|
|
184
|
+
},
|
|
185
|
+
async set(input) {
|
|
186
|
+
if (input.key === 'ratelimit:tenant') {
|
|
187
|
+
throw new Error('write failed');
|
|
188
|
+
}
|
|
189
|
+
internalStore.set(input.key, input.value);
|
|
190
|
+
ttlByKey.set(input.key, input.ttlSeconds);
|
|
191
|
+
},
|
|
192
|
+
async delete(key) {
|
|
193
|
+
internalStore.delete(key);
|
|
194
|
+
ttlByKey.delete(key);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
const limiter = createRateLimiter({
|
|
198
|
+
windowMs: 60000,
|
|
199
|
+
maxRequests: 2,
|
|
200
|
+
algorithm: 'fixed-window',
|
|
201
|
+
persistence: customStore,
|
|
202
|
+
now: () => currentTime,
|
|
203
|
+
});
|
|
204
|
+
await limiter.check({ key: 'provider' });
|
|
205
|
+
currentTime = 30000;
|
|
206
|
+
await expect(limiter.checkMany({ keys: ['provider', 'tenant'], windowSeconds: 10 })).rejects.toThrow('write failed');
|
|
207
|
+
expect(ttlByKey.get('ratelimit:provider')).toBe(10);
|
|
208
|
+
expect(JSON.parse(internalStore.get('ratelimit:provider') ?? '{}')).toMatchObject({
|
|
209
|
+
count: 1,
|
|
210
|
+
windowStart: 30000,
|
|
211
|
+
expiresAt: 40000,
|
|
212
|
+
});
|
|
213
|
+
});
|
|
112
214
|
it('resets key state', async () => {
|
|
113
215
|
const limiter = createRateLimiter({
|
|
114
216
|
windowMs: 60000,
|
|
@@ -172,6 +274,54 @@ describe('rate-limit', () => {
|
|
|
172
274
|
await limiter.check({ key: 'user1' });
|
|
173
275
|
expect(internalStore.size).toBe(1);
|
|
174
276
|
});
|
|
277
|
+
it('uses atomic compare-and-set across limiter instances', async () => {
|
|
278
|
+
const atomicStore = createMemoryAtomicKVStore();
|
|
279
|
+
const first = createRateLimiter({
|
|
280
|
+
windowMs: 60000,
|
|
281
|
+
maxRequests: 1,
|
|
282
|
+
atomicStore,
|
|
283
|
+
});
|
|
284
|
+
const second = createRateLimiter({
|
|
285
|
+
windowMs: 60000,
|
|
286
|
+
maxRequests: 1,
|
|
287
|
+
atomicStore,
|
|
288
|
+
});
|
|
289
|
+
const results = await Promise.all([
|
|
290
|
+
first.check({ key: 'shared-user' }),
|
|
291
|
+
second.check({ key: 'shared-user' }),
|
|
292
|
+
]);
|
|
293
|
+
expect(results.filter((result) => result.allowed)).toHaveLength(1);
|
|
294
|
+
expect(results.filter((result) => !result.allowed)).toHaveLength(1);
|
|
295
|
+
});
|
|
296
|
+
it('rejects atomic stores that cannot perform compare-and-set', () => {
|
|
297
|
+
expect(() => createRateLimiter({
|
|
298
|
+
windowMs: 60000,
|
|
299
|
+
maxRequests: 1,
|
|
300
|
+
atomicStore: {
|
|
301
|
+
async get() { return null; },
|
|
302
|
+
async set() { },
|
|
303
|
+
async setIfAbsent() { return { inserted: true }; },
|
|
304
|
+
async delete() { },
|
|
305
|
+
async incr() { return { value: 1 }; },
|
|
306
|
+
},
|
|
307
|
+
})).toThrow('RATE_LIMIT_ATOMIC_CAS_REQUIRED');
|
|
308
|
+
});
|
|
309
|
+
it('records persistence latency through configured observability', async () => {
|
|
310
|
+
const observability = createObservability({ service: 'middleware-test' });
|
|
311
|
+
const request = observability.startRequest({ requestId: 'req_rate_limit' });
|
|
312
|
+
const limiter = createRateLimiter({
|
|
313
|
+
windowMs: 60000,
|
|
314
|
+
maxRequests: 2,
|
|
315
|
+
persistence: createInMemoryKVStore(),
|
|
316
|
+
observability,
|
|
317
|
+
component: 'test.rate-limit',
|
|
318
|
+
});
|
|
319
|
+
await observability.runWithRequest(request, () => limiter.check({ key: 'observed' }));
|
|
320
|
+
const snapshot = request.finish({ status: 200 });
|
|
321
|
+
const cache = readObservationGroup(snapshot, 'cache');
|
|
322
|
+
expect(cache.count).toBeGreaterThan(0);
|
|
323
|
+
expect(snapshot.metrics[0]?.component).toBe('test.rate-limit.cache');
|
|
324
|
+
});
|
|
175
325
|
});
|
|
176
326
|
describe('createInMemoryKVStore', () => {
|
|
177
327
|
let store;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@superfunctions/middleware",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shared middleware utilities for Superfunctions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -34,13 +34,16 @@
|
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": { "access": "public" },
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@superfunctions/db": "0.
|
|
37
|
+
"@superfunctions/db": "0.2.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
|
40
40
|
"@superfunctions/db": { "optional": true }
|
|
41
41
|
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@superfunctions/observability": "0.0.1"
|
|
44
|
+
},
|
|
42
45
|
"devDependencies": {
|
|
43
|
-
"@superfunctions/db": "^0.
|
|
46
|
+
"@superfunctions/db": "^0.2.0",
|
|
44
47
|
"@types/node": "^22.0.0",
|
|
45
48
|
"typescript": "^5.6.0",
|
|
46
49
|
"vitest": "^3.2.4"
|