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.
@@ -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;