limitforge 0.0.0-stage → 8.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +461 -3
- package/index.d.ts +192 -0
- package/package.json +81 -4
- package/src/benchmark/benchmarkRunner.js +235 -0
- package/src/index.js +30 -0
- package/src/limiter/rateLimiter.js +670 -0
- package/src/resilience/circuitBreaker.js +216 -0
- package/src/resilience/errors.js +29 -0
- package/src/resilience/timeoutGuard.js +24 -0
- package/src/scripts/fixedWindow.lua +10 -0
- package/src/scripts/slidingWindow.lua +26 -0
- package/src/scripts/tokenBucket.lua +66 -0
- package/src/stores/memoryStore.js +238 -0
- package/src/stores/redisStore.js +213 -0
- package/src/stores/resilientStore.js +121 -0
- package/src/telemetry/metricsCollector.js +212 -0
- package/src/telemetry/openTelemetryBridge.js +133 -0
- package/src/telemetry/prometheusExporter.js +131 -0
- package/src/telemetry/telemetryEvents.js +58 -0
- package/src/utils/keyBuilder.js +41 -0
- package/src/utils/routeNormalizer.js +49 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { MemoryStore } from '../stores/memoryStore.js';
|
|
2
|
+
import { RedisStore } from '../stores/redisStore.js';
|
|
3
|
+
import { ResilientStore } from '../stores/resilientStore.js';
|
|
4
|
+
import { buildRateLimitKey } from '../utils/keyBuilder.js';
|
|
5
|
+
import { withTimeout } from '../resilience/timeoutGuard.js';
|
|
6
|
+
import { CircuitBreaker } from '../resilience/circuitBreaker.js';
|
|
7
|
+
import { CircuitBreakerOpenError, StoreTimeoutError } from '../resilience/errors.js';
|
|
8
|
+
import { normalizeRoute } from '../utils/routeNormalizer.js';
|
|
9
|
+
import { MetricsCollector, defaultMetricsCollector } from '../telemetry/metricsCollector.js';
|
|
10
|
+
import { OpenTelemetryBridge } from '../telemetry/openTelemetryBridge.js';
|
|
11
|
+
|
|
12
|
+
const defaultMemoryStore = new MemoryStore();
|
|
13
|
+
const attachedBreakerCollectors = new WeakMap();
|
|
14
|
+
|
|
15
|
+
export function cleanupExpiredRecords() {
|
|
16
|
+
return defaultMemoryStore.cleanupExpiredRecords();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const defaultKeyGenerator = (req) => req.ip || req.socket?.remoteAddress || '127.0.0.1';
|
|
20
|
+
const SUPPORTED_ALGORITHMS = Object.freeze(['fixed-window', 'sliding-window', 'token-bucket']);
|
|
21
|
+
|
|
22
|
+
function validateOptions(options) {
|
|
23
|
+
if (!options || typeof options !== 'object') {
|
|
24
|
+
throw new TypeError('SmartRate: Options must be an object.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const {
|
|
28
|
+
limit,
|
|
29
|
+
windowMs,
|
|
30
|
+
store,
|
|
31
|
+
algorithm = 'fixed-window',
|
|
32
|
+
keyGenerator,
|
|
33
|
+
capacity,
|
|
34
|
+
refillRate,
|
|
35
|
+
refillIntervalMs,
|
|
36
|
+
cost,
|
|
37
|
+
timeoutMs,
|
|
38
|
+
onStoreError,
|
|
39
|
+
circuitBreaker,
|
|
40
|
+
failureThreshold,
|
|
41
|
+
resetTimeoutMs,
|
|
42
|
+
successThreshold,
|
|
43
|
+
fallbackStore,
|
|
44
|
+
metrics,
|
|
45
|
+
metricsCollector,
|
|
46
|
+
routeNormalizer,
|
|
47
|
+
openTelemetry,
|
|
48
|
+
hashClientIdentifier,
|
|
49
|
+
breakerId
|
|
50
|
+
} = options;
|
|
51
|
+
|
|
52
|
+
if (breakerId !== undefined && (typeof breakerId !== 'string' || breakerId.trim().length === 0)) {
|
|
53
|
+
throw new TypeError("SmartRate: 'breakerId' must be a non-empty string.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
hashClientIdentifier !== undefined &&
|
|
58
|
+
typeof hashClientIdentifier !== 'boolean' &&
|
|
59
|
+
typeof hashClientIdentifier !== 'function'
|
|
60
|
+
) {
|
|
61
|
+
throw new TypeError("SmartRate: 'hashClientIdentifier' must be a boolean or a hash function.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (openTelemetry !== undefined) {
|
|
65
|
+
if (
|
|
66
|
+
typeof openTelemetry !== 'boolean' &&
|
|
67
|
+
(typeof openTelemetry !== 'object' || openTelemetry === null)
|
|
68
|
+
) {
|
|
69
|
+
throw new TypeError("SmartRate: 'openTelemetry' must be a boolean, an options object, or an OpenTelemetryBridge instance.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (metrics !== undefined && typeof metrics !== 'boolean') {
|
|
74
|
+
throw new TypeError("SmartRate: 'metrics' must be a boolean.");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (metricsCollector !== undefined && (typeof metricsCollector !== 'object' || metricsCollector === null || typeof metricsCollector.recordRequest !== 'function')) {
|
|
78
|
+
throw new TypeError("SmartRate: 'metricsCollector' must be an object implementing a recordRequest() method.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (routeNormalizer !== undefined && typeof routeNormalizer !== 'function') {
|
|
82
|
+
throw new TypeError("SmartRate: 'routeNormalizer' must be a function.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (fallbackStore !== undefined) {
|
|
86
|
+
if (typeof fallbackStore !== 'boolean' && (typeof fallbackStore !== 'object' || fallbackStore === null || typeof fallbackStore.consume !== 'function')) {
|
|
87
|
+
throw new TypeError("SmartRate: 'fallbackStore' must be a boolean or an object implementing a consume() method.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (timeoutMs !== undefined && (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
|
92
|
+
throw new RangeError(`SmartRate: 'timeoutMs' must be a positive number in milliseconds (received: ${timeoutMs}).`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const VALID_STORE_ERROR_MODES = ['fail-open', 'fail-closed', 'error'];
|
|
96
|
+
if (
|
|
97
|
+
onStoreError !== undefined &&
|
|
98
|
+
typeof onStoreError !== 'function' &&
|
|
99
|
+
!VALID_STORE_ERROR_MODES.includes(onStoreError)
|
|
100
|
+
) {
|
|
101
|
+
throw new TypeError(
|
|
102
|
+
`SmartRate: 'onStoreError' must be 'fail-open', 'fail-closed', 'error', or a function (received: ${onStoreError}).`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (circuitBreaker !== undefined) {
|
|
107
|
+
if (typeof circuitBreaker !== 'boolean' && (typeof circuitBreaker !== 'object' || circuitBreaker === null)) {
|
|
108
|
+
throw new TypeError("SmartRate: 'circuitBreaker' must be a boolean, an options object, or a CircuitBreaker instance.");
|
|
109
|
+
}
|
|
110
|
+
if (typeof circuitBreaker === 'object' && !(circuitBreaker instanceof CircuitBreaker)) {
|
|
111
|
+
const cbFailureThreshold = circuitBreaker.failureThreshold;
|
|
112
|
+
const cbResetTimeoutMs = circuitBreaker.resetTimeoutMs;
|
|
113
|
+
const cbSuccessThreshold = circuitBreaker.successThreshold;
|
|
114
|
+
|
|
115
|
+
if (cbFailureThreshold !== undefined && (typeof cbFailureThreshold !== 'number' || !Number.isInteger(cbFailureThreshold) || cbFailureThreshold <= 0)) {
|
|
116
|
+
throw new RangeError(`SmartRate: CircuitBreaker 'failureThreshold' must be a positive integer (received: ${cbFailureThreshold}).`);
|
|
117
|
+
}
|
|
118
|
+
if (cbResetTimeoutMs !== undefined && (typeof cbResetTimeoutMs !== 'number' || !Number.isFinite(cbResetTimeoutMs) || cbResetTimeoutMs <= 0)) {
|
|
119
|
+
throw new RangeError(`SmartRate: CircuitBreaker 'resetTimeoutMs' must be a positive number in milliseconds (received: ${cbResetTimeoutMs}).`);
|
|
120
|
+
}
|
|
121
|
+
if (cbSuccessThreshold !== undefined && (typeof cbSuccessThreshold !== 'number' || !Number.isInteger(cbSuccessThreshold) || cbSuccessThreshold <= 0)) {
|
|
122
|
+
throw new RangeError(`SmartRate: CircuitBreaker 'successThreshold' must be a positive integer (received: ${cbSuccessThreshold}).`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (failureThreshold !== undefined && (typeof failureThreshold !== 'number' || !Number.isInteger(failureThreshold) || failureThreshold <= 0)) {
|
|
128
|
+
throw new RangeError(`SmartRate: CircuitBreaker 'failureThreshold' must be a positive integer (received: ${failureThreshold}).`);
|
|
129
|
+
}
|
|
130
|
+
if (resetTimeoutMs !== undefined && (typeof resetTimeoutMs !== 'number' || !Number.isFinite(resetTimeoutMs) || resetTimeoutMs <= 0)) {
|
|
131
|
+
throw new RangeError(`SmartRate: CircuitBreaker 'resetTimeoutMs' must be a positive number in milliseconds (received: ${resetTimeoutMs}).`);
|
|
132
|
+
}
|
|
133
|
+
if (successThreshold !== undefined && (typeof successThreshold !== 'number' || !Number.isInteger(successThreshold) || successThreshold <= 0)) {
|
|
134
|
+
throw new RangeError(`SmartRate: CircuitBreaker 'successThreshold' must be a positive integer (received: ${successThreshold}).`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (keyGenerator !== undefined && typeof keyGenerator !== 'function') {
|
|
138
|
+
throw new TypeError("SmartRate: 'keyGenerator' must be a function.");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (typeof algorithm !== 'string' || !SUPPORTED_ALGORITHMS.includes(algorithm)) {
|
|
142
|
+
throw new TypeError(
|
|
143
|
+
`SmartRate: Unsupported algorithm '${algorithm}'. Supported algorithms: ${SUPPORTED_ALGORITHMS.join(', ')}.`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (algorithm === 'token-bucket') {
|
|
148
|
+
const hasCapacity = capacity !== undefined || limit !== undefined;
|
|
149
|
+
if (!hasCapacity) {
|
|
150
|
+
throw new RangeError(
|
|
151
|
+
"SmartRate: Token Bucket requires a positive integer 'capacity' (or 'limit') (received: undefined)."
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (capacity !== undefined) {
|
|
156
|
+
if (typeof capacity !== 'function' && (typeof capacity !== 'number' || !Number.isInteger(capacity) || capacity <= 0)) {
|
|
157
|
+
throw new RangeError(
|
|
158
|
+
`SmartRate: Token Bucket requires a positive integer 'capacity' (or 'limit') (received: ${capacity}).`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
} else if (limit !== undefined) {
|
|
162
|
+
if (typeof limit !== 'function' && (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0)) {
|
|
163
|
+
throw new RangeError(
|
|
164
|
+
`SmartRate: Token Bucket requires a positive integer 'capacity' (or 'limit') (received: ${limit}).`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const hasRefill = refillRate !== undefined || (limit !== undefined && windowMs !== undefined);
|
|
170
|
+
if (!hasRefill) {
|
|
171
|
+
throw new RangeError(
|
|
172
|
+
"SmartRate: Token Bucket requires a positive number 'refillRate' (or 'limit' and 'windowMs') (received: undefined)."
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (refillRate !== undefined) {
|
|
177
|
+
if (typeof refillRate !== 'function' && (typeof refillRate !== 'number' || !Number.isFinite(refillRate) || refillRate <= 0)) {
|
|
178
|
+
throw new RangeError(
|
|
179
|
+
`SmartRate: Token Bucket requires a positive number 'refillRate' (or 'limit' and 'windowMs') (received: ${refillRate}).`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (refillIntervalMs !== undefined) {
|
|
185
|
+
if (typeof refillIntervalMs !== 'function' && (typeof refillIntervalMs !== 'number' || !Number.isFinite(refillIntervalMs) || refillIntervalMs <= 0)) {
|
|
186
|
+
throw new RangeError(
|
|
187
|
+
`SmartRate: Token Bucket requires a positive number 'refillIntervalMs' (received: ${refillIntervalMs}).`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (cost !== undefined) {
|
|
193
|
+
if (typeof cost !== 'number' && typeof cost !== 'function') {
|
|
194
|
+
throw new TypeError("SmartRate: 'cost' must be a positive integer or a function.");
|
|
195
|
+
}
|
|
196
|
+
if (typeof cost === 'number' && (!Number.isInteger(cost) || cost <= 0)) {
|
|
197
|
+
throw new RangeError(`SmartRate: 'cost' must be a positive integer (received: ${cost}).`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
if (typeof limit !== 'function' && (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0)) {
|
|
202
|
+
throw new RangeError(`SmartRate: 'limit' must be a positive integer (received: ${limit}).`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof windowMs !== 'function' && (typeof windowMs !== 'number' || !Number.isFinite(windowMs) || windowMs <= 0)) {
|
|
206
|
+
throw new RangeError(`SmartRate: 'windowMs' must be a positive number in milliseconds (received: ${windowMs}).`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (store !== undefined && (!store || typeof store.consume !== 'function')) {
|
|
211
|
+
throw new TypeError("SmartRate: 'store' must be an object implementing a consume() method.");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function getRouteIdentifier(req) {
|
|
216
|
+
if (req.originalUrl) {
|
|
217
|
+
return req.originalUrl.split('?')[0];
|
|
218
|
+
}
|
|
219
|
+
return `${req.baseUrl || ''}${req.path || ''}` || '/';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function setRateLimitHeaders(res, { limit, remaining, reset, retryAfter }) {
|
|
223
|
+
if (typeof res.setHeader !== 'function' || res.headersSent) return;
|
|
224
|
+
|
|
225
|
+
res.setHeader('RateLimit-Limit', String(limit));
|
|
226
|
+
res.setHeader('RateLimit-Remaining', String(Math.max(0, remaining)));
|
|
227
|
+
res.setHeader('RateLimit-Reset', String(Math.max(1, reset)));
|
|
228
|
+
|
|
229
|
+
if (retryAfter !== undefined) {
|
|
230
|
+
res.setHeader('Retry-After', String(Math.max(1, Math.ceil(retryAfter))));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function rateLimiter(options = {}) {
|
|
235
|
+
validateOptions(options);
|
|
236
|
+
|
|
237
|
+
const {
|
|
238
|
+
limit,
|
|
239
|
+
windowMs,
|
|
240
|
+
capacity,
|
|
241
|
+
refillRate,
|
|
242
|
+
refillIntervalMs = 1000,
|
|
243
|
+
cost = 1,
|
|
244
|
+
timeoutMs,
|
|
245
|
+
onStoreError,
|
|
246
|
+
circuitBreaker,
|
|
247
|
+
failureThreshold,
|
|
248
|
+
resetTimeoutMs,
|
|
249
|
+
successThreshold,
|
|
250
|
+
fallbackStore,
|
|
251
|
+
metrics,
|
|
252
|
+
metricsCollector,
|
|
253
|
+
routeNormalizer,
|
|
254
|
+
openTelemetry,
|
|
255
|
+
hashClientIdentifier = false,
|
|
256
|
+
breakerId
|
|
257
|
+
} = options;
|
|
258
|
+
|
|
259
|
+
const algorithm = options.algorithm || 'fixed-window';
|
|
260
|
+
let store = options.store || defaultMemoryStore;
|
|
261
|
+
const keyGenerator = options.keyGenerator || defaultKeyGenerator;
|
|
262
|
+
const collector = metricsCollector || (metrics ? defaultMetricsCollector : null);
|
|
263
|
+
|
|
264
|
+
let otelBridge = null;
|
|
265
|
+
if (openTelemetry instanceof OpenTelemetryBridge) {
|
|
266
|
+
otelBridge = openTelemetry;
|
|
267
|
+
} else if (typeof openTelemetry === 'object' && openTelemetry !== null) {
|
|
268
|
+
otelBridge = typeof openTelemetry.recordEvaluation === 'function' ? openTelemetry : new OpenTelemetryBridge(openTelemetry);
|
|
269
|
+
} else if (openTelemetry === true) {
|
|
270
|
+
otelBridge = new OpenTelemetryBridge();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let breaker = null;
|
|
274
|
+
if (circuitBreaker instanceof CircuitBreaker) {
|
|
275
|
+
breaker = circuitBreaker;
|
|
276
|
+
} else if (typeof circuitBreaker === 'object' && circuitBreaker !== null) {
|
|
277
|
+
breaker = new CircuitBreaker(circuitBreaker);
|
|
278
|
+
} else if (circuitBreaker === true) {
|
|
279
|
+
breaker = new CircuitBreaker({ failureThreshold, resetTimeoutMs, successThreshold });
|
|
280
|
+
} else if (failureThreshold !== undefined || resetTimeoutMs !== undefined || successThreshold !== undefined) {
|
|
281
|
+
breaker = new CircuitBreaker({ failureThreshold, resetTimeoutMs, successThreshold });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (store instanceof ResilientStore) {
|
|
285
|
+
const hasBreakerOptions =
|
|
286
|
+
circuitBreaker !== undefined ||
|
|
287
|
+
failureThreshold !== undefined ||
|
|
288
|
+
resetTimeoutMs !== undefined ||
|
|
289
|
+
successThreshold !== undefined;
|
|
290
|
+
if (hasBreakerOptions && breaker !== store.circuitBreaker) {
|
|
291
|
+
throw new TypeError("SmartRate: Configure the circuit breaker on the ResilientStore when using it as the store.");
|
|
292
|
+
}
|
|
293
|
+
breaker = store.circuitBreaker;
|
|
294
|
+
} else if (fallbackStore) {
|
|
295
|
+
store = new ResilientStore({
|
|
296
|
+
primaryStore: store,
|
|
297
|
+
fallbackStore: fallbackStore === true ? undefined : fallbackStore,
|
|
298
|
+
circuitBreaker: circuitBreaker === false ? false : (breaker || undefined),
|
|
299
|
+
timeoutMs
|
|
300
|
+
});
|
|
301
|
+
breaker = store.circuitBreaker;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const activeBreakerId =
|
|
305
|
+
breakerId ||
|
|
306
|
+
breaker?.id ||
|
|
307
|
+
(typeof circuitBreaker === 'object' && circuitBreaker?.id ? circuitBreaker.id : null) ||
|
|
308
|
+
null;
|
|
309
|
+
|
|
310
|
+
if (breaker && activeBreakerId && !breaker.id) {
|
|
311
|
+
breaker.id = activeBreakerId;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (collector && breaker) {
|
|
315
|
+
let collectorsForBreaker = attachedBreakerCollectors.get(breaker);
|
|
316
|
+
if (!collectorsForBreaker) {
|
|
317
|
+
collectorsForBreaker = new Set();
|
|
318
|
+
attachedBreakerCollectors.set(breaker, collectorsForBreaker);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!collectorsForBreaker.has(collector)) {
|
|
322
|
+
collectorsForBreaker.add(collector);
|
|
323
|
+
const breakerLabels = activeBreakerId ? { breaker: activeBreakerId } : {};
|
|
324
|
+
const recordCircuitBreakerState = (state) => {
|
|
325
|
+
try {
|
|
326
|
+
if (typeof collector.recordCircuitBreakerState === 'function') {
|
|
327
|
+
collector.recordCircuitBreakerState(state, breakerLabels);
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// safe telemetry boundary
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
breaker.on('stateChange', (evt) => recordCircuitBreakerState(evt.to));
|
|
334
|
+
try {
|
|
335
|
+
recordCircuitBreakerState(breaker.getState());
|
|
336
|
+
} catch {
|
|
337
|
+
// safe telemetry boundary
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const isResilient = store instanceof ResilientStore;
|
|
343
|
+
const safeTelemetry = (fn) => {
|
|
344
|
+
try {
|
|
345
|
+
fn();
|
|
346
|
+
} catch {
|
|
347
|
+
// safe telemetry boundary
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const rateLimiterMiddleware = async function rateLimiterMiddleware(req, res, next) {
|
|
352
|
+
let childSpan = null;
|
|
353
|
+
if (otelBridge && typeof otelBridge.startSpan === 'function') {
|
|
354
|
+
childSpan = otelBridge.startSpan('smartrate.consume', {
|
|
355
|
+
attributes: {
|
|
356
|
+
'smartrate.algorithm': algorithm,
|
|
357
|
+
'ratelimit.algorithm': algorithm
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
if (childSpan) {
|
|
361
|
+
req._smartRateSpan = childSpan;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const rawIdentifier = await keyGenerator(req);
|
|
367
|
+
const clientIdentifier = (rawIdentifier !== undefined && rawIdentifier !== null && String(rawIdentifier).trim().length > 0)
|
|
368
|
+
? String(rawIdentifier).trim()
|
|
369
|
+
: (req.ip || req.socket?.remoteAddress || '127.0.0.1');
|
|
370
|
+
|
|
371
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
372
|
+
const routeKey = getRouteIdentifier(req);
|
|
373
|
+
const normalizedRoute = (collector || otelBridge) ? normalizeRoute(req, routeNormalizer) : '/';
|
|
374
|
+
const defaultStoreLabel = isResilient
|
|
375
|
+
? 'resilient'
|
|
376
|
+
: (store instanceof RedisStore ? 'redis' : (store instanceof MemoryStore ? 'memory' : 'custom'));
|
|
377
|
+
const primaryStore = isResilient ? store.primaryStore : store;
|
|
378
|
+
const primaryStoreLabel = primaryStore instanceof RedisStore
|
|
379
|
+
? 'redis'
|
|
380
|
+
: (primaryStore instanceof MemoryStore ? 'memory' : (primaryStore?.name || 'primary'));
|
|
381
|
+
|
|
382
|
+
const key = buildRateLimitKey({
|
|
383
|
+
algorithm,
|
|
384
|
+
method,
|
|
385
|
+
route: routeKey,
|
|
386
|
+
clientIdentifier,
|
|
387
|
+
hashClientIdentifier
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const resolvedLimit = typeof limit === 'function' ? await limit(req) : limit;
|
|
391
|
+
const resolvedWindowMs = typeof windowMs === 'function' ? await windowMs(req) : windowMs;
|
|
392
|
+
|
|
393
|
+
const resolvedCapacity = typeof capacity === 'function'
|
|
394
|
+
? await capacity(req)
|
|
395
|
+
: (capacity ?? resolvedLimit);
|
|
396
|
+
|
|
397
|
+
const resolvedRefillIntervalMs = typeof refillIntervalMs === 'function'
|
|
398
|
+
? await refillIntervalMs(req)
|
|
399
|
+
: (refillIntervalMs ?? 1000);
|
|
400
|
+
|
|
401
|
+
let resolvedRefillRate;
|
|
402
|
+
if (typeof refillRate === 'function') {
|
|
403
|
+
resolvedRefillRate = await refillRate(req);
|
|
404
|
+
} else if (refillRate !== undefined) {
|
|
405
|
+
resolvedRefillRate = refillRate;
|
|
406
|
+
} else if (resolvedLimit && resolvedWindowMs) {
|
|
407
|
+
resolvedRefillRate = resolvedLimit * (resolvedRefillIntervalMs / resolvedWindowMs);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let requestCost = 1;
|
|
411
|
+
if (typeof cost === 'function') {
|
|
412
|
+
requestCost = await cost(req);
|
|
413
|
+
} else if (typeof cost === 'number') {
|
|
414
|
+
requestCost = cost;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (algorithm === 'token-bucket') {
|
|
418
|
+
if (typeof resolvedCapacity !== 'number' || !Number.isInteger(resolvedCapacity) || resolvedCapacity <= 0) {
|
|
419
|
+
throw new RangeError(`SmartRate: Dynamic 'capacity' must resolve to a positive integer (received: ${resolvedCapacity}).`);
|
|
420
|
+
}
|
|
421
|
+
if (typeof resolvedRefillRate !== 'number' || !Number.isFinite(resolvedRefillRate) || resolvedRefillRate <= 0) {
|
|
422
|
+
throw new RangeError(`SmartRate: Dynamic 'refillRate' must resolve to a positive number (received: ${resolvedRefillRate}).`);
|
|
423
|
+
}
|
|
424
|
+
if (typeof resolvedRefillIntervalMs !== 'number' || !Number.isFinite(resolvedRefillIntervalMs) || resolvedRefillIntervalMs <= 0) {
|
|
425
|
+
throw new RangeError(`SmartRate: Dynamic 'refillIntervalMs' must resolve to a positive number (received: ${resolvedRefillIntervalMs}).`);
|
|
426
|
+
}
|
|
427
|
+
if (typeof requestCost !== 'number' || !Number.isInteger(requestCost) || requestCost <= 0) {
|
|
428
|
+
throw new RangeError(`SmartRate: Dynamic 'cost' must resolve to a positive integer (received: ${requestCost}).`);
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
if (typeof resolvedLimit !== 'number' || !Number.isInteger(resolvedLimit) || resolvedLimit <= 0) {
|
|
432
|
+
throw new RangeError(`SmartRate: Dynamic 'limit' must resolve to a positive integer (received: ${resolvedLimit}).`);
|
|
433
|
+
}
|
|
434
|
+
if (typeof resolvedWindowMs !== 'number' || !Number.isFinite(resolvedWindowMs) || resolvedWindowMs <= 0) {
|
|
435
|
+
throw new RangeError(`SmartRate: Dynamic 'windowMs' must resolve to a positive number in milliseconds (received: ${resolvedWindowMs}).`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
let result;
|
|
440
|
+
let storeStart = 0;
|
|
441
|
+
if (collector) {
|
|
442
|
+
storeStart = process.hrtime.bigint();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
const consumeOp = () => {
|
|
447
|
+
const storeOperation = store.consume({
|
|
448
|
+
key,
|
|
449
|
+
limit: resolvedLimit,
|
|
450
|
+
windowMs: resolvedWindowMs,
|
|
451
|
+
algorithm,
|
|
452
|
+
capacity: resolvedCapacity,
|
|
453
|
+
refillRate: resolvedRefillRate,
|
|
454
|
+
refillIntervalMs: resolvedRefillIntervalMs,
|
|
455
|
+
cost: requestCost
|
|
456
|
+
});
|
|
457
|
+
return (timeoutMs === undefined || isResilient) ? storeOperation : withTimeout(storeOperation, timeoutMs);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
result = (breaker && !isResilient) ? await breaker.execute(consumeOp) : await consumeOp();
|
|
461
|
+
|
|
462
|
+
if (collector && storeStart > 0) {
|
|
463
|
+
const durationSec = Number(process.hrtime.bigint() - storeStart) / 1e9;
|
|
464
|
+
const activeStore = result.store || (result.fallbackUsed ? 'fallback' : defaultStoreLabel);
|
|
465
|
+
safeTelemetry(() => collector.recordStoreDuration(durationSec, { store: activeStore }));
|
|
466
|
+
}
|
|
467
|
+
} catch (storeError) {
|
|
468
|
+
if (collector && storeStart > 0) {
|
|
469
|
+
const durationSec = Number(process.hrtime.bigint() - storeStart) / 1e9;
|
|
470
|
+
safeTelemetry(() => collector.recordStoreDuration(durationSec, { store: defaultStoreLabel }));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (typeof res.setHeader === 'function' && !res.headersSent) {
|
|
474
|
+
res.setHeader('RateLimit-Degraded', 'true');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
req.rateLimit = {
|
|
478
|
+
degraded: true,
|
|
479
|
+
storeError
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
if (collector) {
|
|
483
|
+
safeTelemetry(() => {
|
|
484
|
+
const errorType =
|
|
485
|
+
storeError instanceof StoreTimeoutError
|
|
486
|
+
? 'timeout'
|
|
487
|
+
: storeError instanceof CircuitBreakerOpenError
|
|
488
|
+
? 'circuit_open'
|
|
489
|
+
: (storeError?.code === 'ECONNREFUSED' ? 'econnrefused' : 'store_error');
|
|
490
|
+
|
|
491
|
+
collector.recordStoreError({ store: defaultStoreLabel, error_type: errorType });
|
|
492
|
+
collector.recordDegradedRequest({ reason: errorType });
|
|
493
|
+
|
|
494
|
+
if (onStoreError === 'fail-open') {
|
|
495
|
+
collector.recordRequest({
|
|
496
|
+
outcome: 'allowed',
|
|
497
|
+
algorithm,
|
|
498
|
+
method,
|
|
499
|
+
normalized_route: normalizedRoute,
|
|
500
|
+
store: defaultStoreLabel
|
|
501
|
+
});
|
|
502
|
+
} else if (onStoreError === 'fail-closed') {
|
|
503
|
+
collector.recordRequest({
|
|
504
|
+
outcome: 'blocked',
|
|
505
|
+
algorithm,
|
|
506
|
+
method,
|
|
507
|
+
normalized_route: normalizedRoute,
|
|
508
|
+
store: defaultStoreLabel
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (otelBridge) {
|
|
515
|
+
safeTelemetry(() => {
|
|
516
|
+
otelBridge.recordStoreError({ req, storeError, store: defaultStoreLabel });
|
|
517
|
+
if (onStoreError === 'fail-open' || onStoreError === 'fail-closed') {
|
|
518
|
+
otelBridge.recordEvaluation({
|
|
519
|
+
req,
|
|
520
|
+
result: {
|
|
521
|
+
allowed: onStoreError === 'fail-open',
|
|
522
|
+
remaining: 0,
|
|
523
|
+
reset: 30,
|
|
524
|
+
retryAfter: 30
|
|
525
|
+
},
|
|
526
|
+
algorithm,
|
|
527
|
+
normalizedRoute,
|
|
528
|
+
store: defaultStoreLabel
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (onStoreError === 'fail-open') {
|
|
535
|
+
return next();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (onStoreError === 'fail-closed') {
|
|
539
|
+
let retryAfterSeconds = 30;
|
|
540
|
+
|
|
541
|
+
if (storeError instanceof CircuitBreakerOpenError && typeof storeError.resetTimeoutMs === 'number') {
|
|
542
|
+
retryAfterSeconds = Math.max(1, Math.ceil(storeError.resetTimeoutMs / 1000));
|
|
543
|
+
} else if (breaker && breaker.isOpen()) {
|
|
544
|
+
const remainingMs = Math.max(0, breaker.nextAttempt - Date.now());
|
|
545
|
+
retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (typeof res.setHeader === 'function' && !res.headersSent) {
|
|
549
|
+
res.setHeader('Retry-After', String(retryAfterSeconds));
|
|
550
|
+
}
|
|
551
|
+
return res.status(503).json({
|
|
552
|
+
success: false,
|
|
553
|
+
error: 'Service Unavailable',
|
|
554
|
+
message: 'Rate limiting service temporarily unavailable'
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (typeof onStoreError === 'function') {
|
|
559
|
+
return await onStoreError(storeError, req, res, next);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return next(storeError);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (result.degraded) {
|
|
566
|
+
if (typeof res.setHeader === 'function' && !res.headersSent) {
|
|
567
|
+
res.setHeader('RateLimit-Degraded', 'true');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const primaryErrorName = typeof result.primaryError?.name === 'string' && result.primaryError.name
|
|
571
|
+
? result.primaryError.name
|
|
572
|
+
: 'Error';
|
|
573
|
+
|
|
574
|
+
req.rateLimit = {
|
|
575
|
+
degraded: true,
|
|
576
|
+
fallbackUsed: Boolean(result.fallbackUsed),
|
|
577
|
+
store: result.store,
|
|
578
|
+
primaryError: primaryErrorName
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (collector) {
|
|
583
|
+
safeTelemetry(() => {
|
|
584
|
+
if (result.degraded) {
|
|
585
|
+
collector.recordDegradedRequest({
|
|
586
|
+
reason: result.primaryError?.name || 'fallback'
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
if (result.primaryError) {
|
|
590
|
+
const errorType =
|
|
591
|
+
result.primaryError instanceof StoreTimeoutError
|
|
592
|
+
? 'timeout'
|
|
593
|
+
: result.primaryError instanceof CircuitBreakerOpenError
|
|
594
|
+
? 'circuit_open'
|
|
595
|
+
: (result.primaryError?.code === 'ECONNREFUSED' ? 'econnrefused' : 'store_error');
|
|
596
|
+
|
|
597
|
+
collector.recordStoreError({
|
|
598
|
+
store: primaryStoreLabel,
|
|
599
|
+
error_type: errorType
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const activeStore = result.store || (result.fallbackUsed ? 'fallback' : defaultStoreLabel);
|
|
604
|
+
collector.recordRequest({
|
|
605
|
+
outcome: result.allowed ? 'allowed' : 'blocked',
|
|
606
|
+
algorithm,
|
|
607
|
+
method,
|
|
608
|
+
normalized_route: normalizedRoute,
|
|
609
|
+
store: activeStore
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (otelBridge) {
|
|
615
|
+
safeTelemetry(() => {
|
|
616
|
+
if (result.degraded && result.primaryError) {
|
|
617
|
+
otelBridge.recordStoreError({
|
|
618
|
+
req,
|
|
619
|
+
storeError: result.primaryError,
|
|
620
|
+
store: primaryStoreLabel
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
const activeStore = result.store || (result.fallbackUsed ? 'fallback' : defaultStoreLabel);
|
|
624
|
+
otelBridge.recordEvaluation({
|
|
625
|
+
req,
|
|
626
|
+
result,
|
|
627
|
+
algorithm,
|
|
628
|
+
normalizedRoute,
|
|
629
|
+
store: activeStore
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const headerLimit = algorithm === 'token-bucket' ? resolvedCapacity : resolvedLimit;
|
|
635
|
+
|
|
636
|
+
setRateLimitHeaders(res, {
|
|
637
|
+
limit: headerLimit,
|
|
638
|
+
remaining: result.remaining,
|
|
639
|
+
reset: result.reset,
|
|
640
|
+
retryAfter: result.retryAfter
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
if (result.allowed) {
|
|
644
|
+
return next();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return res.status(429).json({
|
|
648
|
+
success: false,
|
|
649
|
+
message: 'Too many requests',
|
|
650
|
+
retryAfter: result.retryAfter
|
|
651
|
+
});
|
|
652
|
+
} catch (err) {
|
|
653
|
+
return next(err);
|
|
654
|
+
} finally {
|
|
655
|
+
if (childSpan && typeof childSpan.end === 'function') {
|
|
656
|
+
try {
|
|
657
|
+
childSpan.end();
|
|
658
|
+
} catch {
|
|
659
|
+
// safe boundary
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
rateLimiterMiddleware.circuitBreaker = breaker;
|
|
666
|
+
rateLimiterMiddleware.metricsCollector = collector;
|
|
667
|
+
return rateLimiterMiddleware;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export default rateLimiter;
|