mastercontroller 1.3.13 → 1.3.15
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/MasterAction.js +302 -62
- package/MasterActionFilters.js +556 -82
- package/MasterControl.js +77 -44
- package/MasterCors.js +61 -19
- package/MasterPipeline.js +29 -6
- package/MasterRequest.js +579 -102
- package/MasterRouter.js +446 -75
- package/MasterSocket.js +380 -15
- package/MasterTemp.js +292 -10
- package/MasterTimeout.js +420 -64
- package/MasterTools.js +478 -77
- package/README.md +505 -0
- package/package.json +1 -1
- package/.claude/settings.local.json +0 -29
- package/.github/workflows/ci.yml +0 -317
- package/PERFORMANCE_SECURITY_AUDIT.md +0 -677
- package/SENIOR_ENGINEER_AUDIT.md +0 -2477
- package/VERIFICATION_CHECKLIST.md +0 -726
- package/log/mastercontroller.log +0 -2
- package/test-json-empty-body.js +0 -76
- package/test-raw-body-preservation.js +0 -128
- package/test-v1.3.4-fixes.js +0 -129
package/MasterTimeout.js
CHANGED
|
@@ -10,18 +10,52 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Inspired by Rails ActionController::Timeout and Django MIDDLEWARE_TIMEOUT
|
|
12
12
|
*
|
|
13
|
-
* @version 1.
|
|
13
|
+
* @version 1.1.0 - FAANG-level refactor with production hardening
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const { logger } = require('./error/MasterErrorLogger');
|
|
17
17
|
|
|
18
|
+
// Configuration Constants
|
|
19
|
+
const TIMEOUT_CONFIG = {
|
|
20
|
+
DEFAULT_TIMEOUT: 120000, // 120 seconds
|
|
21
|
+
MIN_TIMEOUT: 1000, // 1 second minimum
|
|
22
|
+
MAX_TIMEOUT: 3600000, // 1 hour maximum
|
|
23
|
+
MAX_ACTIVE_REQUESTS: 10000, // Prevent memory exhaustion
|
|
24
|
+
HANDLER_TIMEOUT: 5000, // 5 seconds for custom handlers
|
|
25
|
+
CLEANUP_INTERVAL: 60000, // Clean up stale requests every minute
|
|
26
|
+
REQUEST_ID_LENGTH: 15 // Length of generated request IDs
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const HTTP_STATUS = {
|
|
30
|
+
GATEWAY_TIMEOUT: 504,
|
|
31
|
+
INTERNAL_ERROR: 500
|
|
32
|
+
};
|
|
33
|
+
|
|
18
34
|
class MasterTimeout {
|
|
19
35
|
constructor() {
|
|
20
|
-
this.globalTimeout =
|
|
36
|
+
this.globalTimeout = TIMEOUT_CONFIG.DEFAULT_TIMEOUT;
|
|
21
37
|
this.routeTimeouts = new Map();
|
|
22
38
|
this.activeRequests = new Map();
|
|
23
39
|
this.timeoutHandlers = [];
|
|
24
40
|
this.enabled = true;
|
|
41
|
+
|
|
42
|
+
// Metrics tracking
|
|
43
|
+
this.metrics = {
|
|
44
|
+
totalRequests: 0,
|
|
45
|
+
totalTimeouts: 0,
|
|
46
|
+
peakConcurrent: 0,
|
|
47
|
+
totalDuration: 0
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Start periodic cleanup
|
|
51
|
+
this.cleanupTimer = setInterval(() => {
|
|
52
|
+
this._cleanupStaleRequests();
|
|
53
|
+
}, TIMEOUT_CONFIG.CLEANUP_INTERVAL);
|
|
54
|
+
|
|
55
|
+
// Prevent cleanup timer from keeping process alive
|
|
56
|
+
if (this.cleanupTimer.unref) {
|
|
57
|
+
this.cleanupTimer.unref();
|
|
58
|
+
}
|
|
25
59
|
}
|
|
26
60
|
|
|
27
61
|
// Lazy-load master to avoid circular dependency (Google-style lazy initialization)
|
|
@@ -39,17 +73,43 @@ class MasterTimeout {
|
|
|
39
73
|
* @param {Number} options.globalTimeout - Default timeout in ms (default: 120000)
|
|
40
74
|
* @param {Boolean} options.enabled - Enable/disable timeouts (default: true)
|
|
41
75
|
* @param {Function} options.onTimeout - Custom timeout handler
|
|
76
|
+
* @throws {TypeError} If options is not an object
|
|
77
|
+
* @throws {Error} If globalTimeout is out of valid range
|
|
78
|
+
* @throws {TypeError} If enabled is not a boolean
|
|
79
|
+
* @throws {TypeError} If onTimeout is not a function
|
|
42
80
|
*/
|
|
43
81
|
init(options = {}) {
|
|
44
|
-
|
|
82
|
+
// Input validation
|
|
83
|
+
if (typeof options !== 'object' || options === null) {
|
|
84
|
+
throw new TypeError('Options must be an object');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (options.globalTimeout !== undefined) {
|
|
88
|
+
if (typeof options.globalTimeout !== 'number' || isNaN(options.globalTimeout)) {
|
|
89
|
+
throw new TypeError('globalTimeout must be a number');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (options.globalTimeout < TIMEOUT_CONFIG.MIN_TIMEOUT ||
|
|
93
|
+
options.globalTimeout > TIMEOUT_CONFIG.MAX_TIMEOUT) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`globalTimeout must be between ${TIMEOUT_CONFIG.MIN_TIMEOUT}ms and ${TIMEOUT_CONFIG.MAX_TIMEOUT}ms`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
45
99
|
this.globalTimeout = options.globalTimeout;
|
|
46
100
|
}
|
|
47
101
|
|
|
48
102
|
if (options.enabled !== undefined) {
|
|
103
|
+
if (typeof options.enabled !== 'boolean') {
|
|
104
|
+
throw new TypeError('enabled must be a boolean');
|
|
105
|
+
}
|
|
49
106
|
this.enabled = options.enabled;
|
|
50
107
|
}
|
|
51
108
|
|
|
52
|
-
if (options.onTimeout
|
|
109
|
+
if (options.onTimeout !== undefined) {
|
|
110
|
+
if (typeof options.onTimeout !== 'function') {
|
|
111
|
+
throw new TypeError('onTimeout must be a function');
|
|
112
|
+
}
|
|
53
113
|
this.timeoutHandlers.push(options.onTimeout);
|
|
54
114
|
}
|
|
55
115
|
|
|
@@ -66,16 +126,36 @@ class MasterTimeout {
|
|
|
66
126
|
/**
|
|
67
127
|
* Set timeout for specific route pattern
|
|
68
128
|
*
|
|
69
|
-
* @param {String} routePattern - Route pattern (e.g., '/api/*', '/admin/reports')
|
|
129
|
+
* @param {String|RegExp} routePattern - Route pattern (e.g., '/api/*', '/admin/reports')
|
|
70
130
|
* @param {Number} timeout - Timeout in milliseconds
|
|
131
|
+
* @throws {TypeError} If routePattern is not string or RegExp
|
|
132
|
+
* @throws {Error} If routePattern is empty
|
|
133
|
+
* @throws {TypeError} If timeout is not a number
|
|
134
|
+
* @throws {Error} If timeout is out of valid range
|
|
71
135
|
*
|
|
72
136
|
* @example
|
|
73
137
|
* this._master.timeout.setRouteTimeout('/api/*', 30000); // 30 seconds for APIs
|
|
74
138
|
* this._master.timeout.setRouteTimeout('/admin/reports', 300000); // 5 minutes for reports
|
|
75
139
|
*/
|
|
76
140
|
setRouteTimeout(routePattern, timeout) {
|
|
77
|
-
|
|
78
|
-
|
|
141
|
+
// Validate routePattern
|
|
142
|
+
if (typeof routePattern !== 'string' && !(routePattern instanceof RegExp)) {
|
|
143
|
+
throw new TypeError('Route pattern must be a string or RegExp');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (typeof routePattern === 'string' && (!routePattern || routePattern.trim() === '')) {
|
|
147
|
+
throw new Error('Route pattern cannot be empty');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Validate timeout
|
|
151
|
+
if (typeof timeout !== 'number' || isNaN(timeout)) {
|
|
152
|
+
throw new TypeError('Timeout must be a number');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (timeout < TIMEOUT_CONFIG.MIN_TIMEOUT || timeout > TIMEOUT_CONFIG.MAX_TIMEOUT) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Timeout must be between ${TIMEOUT_CONFIG.MIN_TIMEOUT}ms and ${TIMEOUT_CONFIG.MAX_TIMEOUT}ms`
|
|
158
|
+
);
|
|
79
159
|
}
|
|
80
160
|
|
|
81
161
|
this.routeTimeouts.set(routePattern, timeout);
|
|
@@ -83,7 +163,7 @@ class MasterTimeout {
|
|
|
83
163
|
logger.info({
|
|
84
164
|
code: 'MC_TIMEOUT_ROUTE_SET',
|
|
85
165
|
message: 'Route timeout configured',
|
|
86
|
-
routePattern,
|
|
166
|
+
routePattern: routePattern.toString(),
|
|
87
167
|
timeout
|
|
88
168
|
});
|
|
89
169
|
|
|
@@ -98,6 +178,16 @@ class MasterTimeout {
|
|
|
98
178
|
* @returns {Number} - Timeout in milliseconds
|
|
99
179
|
*/
|
|
100
180
|
getTimeoutForPath(path) {
|
|
181
|
+
// Validate input
|
|
182
|
+
if (typeof path !== 'string') {
|
|
183
|
+
logger.warn({
|
|
184
|
+
code: 'MC_TIMEOUT_INVALID_PATH',
|
|
185
|
+
message: 'Invalid path provided to getTimeoutForPath',
|
|
186
|
+
path
|
|
187
|
+
});
|
|
188
|
+
return this.globalTimeout;
|
|
189
|
+
}
|
|
190
|
+
|
|
101
191
|
// Check route-specific timeouts
|
|
102
192
|
for (const [pattern, timeout] of this.routeTimeouts.entries()) {
|
|
103
193
|
if (this._pathMatches(path, pattern)) {
|
|
@@ -113,15 +203,39 @@ class MasterTimeout {
|
|
|
113
203
|
* Start timeout tracking for request
|
|
114
204
|
*
|
|
115
205
|
* @param {Object} ctx - Request context
|
|
116
|
-
* @returns {String} - Request ID
|
|
206
|
+
* @returns {String|null} - Request ID or null if disabled/error
|
|
207
|
+
* @throws {TypeError} If ctx is not an object
|
|
208
|
+
* @throws {Error} If ctx.response is missing
|
|
209
|
+
* @throws {Error} If max active requests exceeded
|
|
117
210
|
*/
|
|
118
211
|
startTracking(ctx) {
|
|
119
212
|
if (!this.enabled) {
|
|
120
213
|
return null;
|
|
121
214
|
}
|
|
122
215
|
|
|
216
|
+
// Input validation
|
|
217
|
+
if (!ctx || typeof ctx !== 'object') {
|
|
218
|
+
throw new TypeError('Context must be an object');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!ctx.response || typeof ctx.response !== 'object') {
|
|
222
|
+
throw new Error('Context must have a response object');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check max active requests (DoS protection)
|
|
226
|
+
if (this.activeRequests.size >= TIMEOUT_CONFIG.MAX_ACTIVE_REQUESTS) {
|
|
227
|
+
logger.error({
|
|
228
|
+
code: 'MC_TIMEOUT_MAX_REQUESTS',
|
|
229
|
+
message: 'Maximum active requests exceeded',
|
|
230
|
+
current: this.activeRequests.size,
|
|
231
|
+
max: TIMEOUT_CONFIG.MAX_ACTIVE_REQUESTS
|
|
232
|
+
});
|
|
233
|
+
throw new Error(`Maximum active requests (${TIMEOUT_CONFIG.MAX_ACTIVE_REQUESTS}) exceeded`);
|
|
234
|
+
}
|
|
235
|
+
|
|
123
236
|
const requestId = this._generateRequestId();
|
|
124
|
-
const
|
|
237
|
+
const path = ctx.pathName || (ctx.request && ctx.request.url) || '/';
|
|
238
|
+
const timeout = this.getTimeoutForPath(path);
|
|
125
239
|
const startTime = Date.now();
|
|
126
240
|
|
|
127
241
|
const timer = setTimeout(() => {
|
|
@@ -132,18 +246,33 @@ class MasterTimeout {
|
|
|
132
246
|
timer,
|
|
133
247
|
timeout,
|
|
134
248
|
startTime,
|
|
135
|
-
path
|
|
136
|
-
method: ctx.type || ctx.request.method.toLowerCase()
|
|
249
|
+
path,
|
|
250
|
+
method: ctx.type || (ctx.request && ctx.request.method.toLowerCase()) || 'unknown'
|
|
137
251
|
});
|
|
138
252
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
253
|
+
// Update metrics
|
|
254
|
+
this.metrics.totalRequests++;
|
|
255
|
+
if (this.activeRequests.size > this.metrics.peakConcurrent) {
|
|
256
|
+
this.metrics.peakConcurrent = this.activeRequests.size;
|
|
257
|
+
}
|
|
143
258
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
259
|
+
// Attach cleanup to response finish (with error handling)
|
|
260
|
+
try {
|
|
261
|
+
ctx.response.once('finish', () => {
|
|
262
|
+
this.stopTracking(requestId);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
ctx.response.once('close', () => {
|
|
266
|
+
this.stopTracking(requestId);
|
|
267
|
+
});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
logger.warn({
|
|
270
|
+
code: 'MC_TIMEOUT_LISTENER_ATTACH_FAILED',
|
|
271
|
+
message: 'Failed to attach response listeners',
|
|
272
|
+
requestId,
|
|
273
|
+
error: err.message
|
|
274
|
+
});
|
|
275
|
+
}
|
|
147
276
|
|
|
148
277
|
return requestId;
|
|
149
278
|
}
|
|
@@ -152,22 +281,42 @@ class MasterTimeout {
|
|
|
152
281
|
* Stop timeout tracking for request
|
|
153
282
|
*
|
|
154
283
|
* @param {String} requestId - Request ID
|
|
284
|
+
* @returns {Boolean} - True if request was found and stopped
|
|
285
|
+
* @throws {TypeError} If requestId is not a string
|
|
155
286
|
*/
|
|
156
287
|
stopTracking(requestId) {
|
|
288
|
+
// Input validation
|
|
289
|
+
if (typeof requestId !== 'string' || !requestId) {
|
|
290
|
+
throw new TypeError('Request ID must be a non-empty string');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Race condition protection: check if request still exists
|
|
157
294
|
const tracked = this.activeRequests.get(requestId);
|
|
158
295
|
|
|
159
296
|
if (tracked) {
|
|
160
297
|
clearTimeout(tracked.timer);
|
|
298
|
+
|
|
299
|
+
// Update metrics
|
|
300
|
+
const duration = Date.now() - tracked.startTime;
|
|
301
|
+
this.metrics.totalDuration += duration;
|
|
302
|
+
|
|
161
303
|
this.activeRequests.delete(requestId);
|
|
304
|
+
return true;
|
|
162
305
|
}
|
|
306
|
+
|
|
307
|
+
return false;
|
|
163
308
|
}
|
|
164
309
|
|
|
165
310
|
/**
|
|
166
311
|
* Handle request timeout
|
|
167
312
|
*
|
|
168
313
|
* @private
|
|
314
|
+
* @param {String} requestId - Request ID
|
|
315
|
+
* @param {Object} ctx - Request context
|
|
316
|
+
* @param {Number} startTime - Request start timestamp
|
|
169
317
|
*/
|
|
170
318
|
_handleTimeout(requestId, ctx, startTime) {
|
|
319
|
+
// Race condition protection: check if request still exists
|
|
171
320
|
const tracked = this.activeRequests.get(requestId);
|
|
172
321
|
|
|
173
322
|
if (!tracked) {
|
|
@@ -176,6 +325,9 @@ class MasterTimeout {
|
|
|
176
325
|
|
|
177
326
|
const duration = Date.now() - startTime;
|
|
178
327
|
|
|
328
|
+
// Update metrics
|
|
329
|
+
this.metrics.totalTimeouts++;
|
|
330
|
+
|
|
179
331
|
// Log timeout
|
|
180
332
|
logger.error({
|
|
181
333
|
code: 'MC_REQUEST_TIMEOUT',
|
|
@@ -187,39 +339,138 @@ class MasterTimeout {
|
|
|
187
339
|
duration
|
|
188
340
|
});
|
|
189
341
|
|
|
190
|
-
// Call custom handlers
|
|
342
|
+
// Call custom handlers with their own timeout protection
|
|
191
343
|
for (const handler of this.timeoutHandlers) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
344
|
+
this._executeHandlerWithTimeout(handler, ctx, {
|
|
345
|
+
requestId,
|
|
346
|
+
path: tracked.path,
|
|
347
|
+
method: tracked.method,
|
|
348
|
+
timeout: tracked.timeout,
|
|
349
|
+
duration
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Send timeout response if not already sent
|
|
354
|
+
try {
|
|
355
|
+
if (ctx.response && !ctx.response.headersSent) {
|
|
356
|
+
ctx.response.statusCode = HTTP_STATUS.GATEWAY_TIMEOUT;
|
|
357
|
+
ctx.response.setHeader('Content-Type', 'application/json');
|
|
358
|
+
ctx.response.end(JSON.stringify({
|
|
359
|
+
error: 'Request Timeout',
|
|
360
|
+
message: 'The server did not receive a complete request within the allowed time',
|
|
361
|
+
code: 'MC_REQUEST_TIMEOUT',
|
|
197
362
|
timeout: tracked.timeout,
|
|
198
|
-
|
|
199
|
-
});
|
|
200
|
-
}
|
|
363
|
+
path: tracked.path
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
logger.error({
|
|
368
|
+
code: 'MC_TIMEOUT_RESPONSE_FAILED',
|
|
369
|
+
message: 'Failed to send timeout response',
|
|
370
|
+
requestId,
|
|
371
|
+
error: err.message
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Cleanup
|
|
376
|
+
try {
|
|
377
|
+
this.stopTracking(requestId);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
// If stopTracking throws (invalid requestId), just delete directly
|
|
380
|
+
this.activeRequests.delete(requestId);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Execute custom handler with timeout protection
|
|
386
|
+
*
|
|
387
|
+
* @private
|
|
388
|
+
* @param {Function} handler - Custom timeout handler
|
|
389
|
+
* @param {Object} ctx - Request context
|
|
390
|
+
* @param {Object} info - Timeout information
|
|
391
|
+
*/
|
|
392
|
+
_executeHandlerWithTimeout(handler, ctx, info) {
|
|
393
|
+
let handlerCompleted = false;
|
|
394
|
+
|
|
395
|
+
const handlerTimer = setTimeout(() => {
|
|
396
|
+
if (!handlerCompleted) {
|
|
201
397
|
logger.error({
|
|
202
|
-
code: '
|
|
203
|
-
message: 'Timeout handler
|
|
204
|
-
|
|
398
|
+
code: 'MC_TIMEOUT_HANDLER_TIMEOUT',
|
|
399
|
+
message: 'Timeout handler exceeded maximum execution time',
|
|
400
|
+
maxTime: TIMEOUT_CONFIG.HANDLER_TIMEOUT
|
|
205
401
|
});
|
|
206
402
|
}
|
|
403
|
+
}, TIMEOUT_CONFIG.HANDLER_TIMEOUT);
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
const result = handler(ctx, info);
|
|
407
|
+
|
|
408
|
+
// If handler returns a promise, handle it
|
|
409
|
+
if (result && typeof result.then === 'function') {
|
|
410
|
+
result
|
|
411
|
+
.then(() => {
|
|
412
|
+
handlerCompleted = true;
|
|
413
|
+
clearTimeout(handlerTimer);
|
|
414
|
+
})
|
|
415
|
+
.catch(err => {
|
|
416
|
+
handlerCompleted = true;
|
|
417
|
+
clearTimeout(handlerTimer);
|
|
418
|
+
logger.error({
|
|
419
|
+
code: 'MC_TIMEOUT_HANDLER_ERROR',
|
|
420
|
+
message: 'Timeout handler promise rejected',
|
|
421
|
+
error: err.message
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
} else {
|
|
425
|
+
handlerCompleted = true;
|
|
426
|
+
clearTimeout(handlerTimer);
|
|
427
|
+
}
|
|
428
|
+
} catch (err) {
|
|
429
|
+
handlerCompleted = true;
|
|
430
|
+
clearTimeout(handlerTimer);
|
|
431
|
+
logger.error({
|
|
432
|
+
code: 'MC_TIMEOUT_HANDLER_ERROR',
|
|
433
|
+
message: 'Timeout handler threw error',
|
|
434
|
+
error: err.message
|
|
435
|
+
});
|
|
207
436
|
}
|
|
437
|
+
}
|
|
208
438
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
439
|
+
/**
|
|
440
|
+
* Clean up stale requests that somehow weren't cleaned up properly
|
|
441
|
+
*
|
|
442
|
+
* @private
|
|
443
|
+
*/
|
|
444
|
+
_cleanupStaleRequests() {
|
|
445
|
+
const now = Date.now();
|
|
446
|
+
let cleanedCount = 0;
|
|
447
|
+
|
|
448
|
+
for (const [requestId, tracked] of this.activeRequests.entries()) {
|
|
449
|
+
const elapsed = now - tracked.startTime;
|
|
450
|
+
|
|
451
|
+
// If request has been active for more than 2x its timeout, force cleanup
|
|
452
|
+
if (elapsed > tracked.timeout * 2) {
|
|
453
|
+
logger.warn({
|
|
454
|
+
code: 'MC_TIMEOUT_STALE_REQUEST',
|
|
455
|
+
message: 'Cleaning up stale request',
|
|
456
|
+
requestId,
|
|
457
|
+
elapsed,
|
|
458
|
+
timeout: tracked.timeout
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
clearTimeout(tracked.timer);
|
|
462
|
+
this.activeRequests.delete(requestId);
|
|
463
|
+
cleanedCount++;
|
|
464
|
+
}
|
|
219
465
|
}
|
|
220
466
|
|
|
221
|
-
|
|
222
|
-
|
|
467
|
+
if (cleanedCount > 0) {
|
|
468
|
+
logger.info({
|
|
469
|
+
code: 'MC_TIMEOUT_CLEANUP',
|
|
470
|
+
message: 'Stale requests cleaned up',
|
|
471
|
+
count: cleanedCount
|
|
472
|
+
});
|
|
473
|
+
}
|
|
223
474
|
}
|
|
224
475
|
|
|
225
476
|
/**
|
|
@@ -236,14 +487,27 @@ class MasterTimeout {
|
|
|
236
487
|
return;
|
|
237
488
|
}
|
|
238
489
|
|
|
239
|
-
|
|
240
|
-
ctx.requestId = requestId;
|
|
490
|
+
let requestId = null;
|
|
241
491
|
|
|
242
492
|
try {
|
|
493
|
+
requestId = $that.startTracking(ctx);
|
|
494
|
+
ctx.requestId = requestId;
|
|
495
|
+
|
|
243
496
|
await next();
|
|
244
497
|
} catch (err) {
|
|
245
|
-
// Stop tracking on error
|
|
246
|
-
|
|
498
|
+
// Stop tracking on error (with error handling)
|
|
499
|
+
if (requestId) {
|
|
500
|
+
try {
|
|
501
|
+
$that.stopTracking(requestId);
|
|
502
|
+
} catch (stopErr) {
|
|
503
|
+
logger.warn({
|
|
504
|
+
code: 'MC_TIMEOUT_STOP_FAILED',
|
|
505
|
+
message: 'Failed to stop tracking on error',
|
|
506
|
+
requestId,
|
|
507
|
+
error: stopErr.message
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
247
511
|
throw err;
|
|
248
512
|
}
|
|
249
513
|
};
|
|
@@ -272,49 +536,138 @@ class MasterTimeout {
|
|
|
272
536
|
}
|
|
273
537
|
|
|
274
538
|
/**
|
|
275
|
-
*
|
|
539
|
+
* Shutdown timeout system and clean up all resources
|
|
540
|
+
*
|
|
541
|
+
* @returns {Object} - Cleanup statistics
|
|
542
|
+
*/
|
|
543
|
+
shutdown() {
|
|
544
|
+
logger.info({
|
|
545
|
+
code: 'MC_TIMEOUT_SHUTDOWN',
|
|
546
|
+
message: 'Shutting down timeout system',
|
|
547
|
+
activeRequests: this.activeRequests.size
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Clear cleanup timer
|
|
551
|
+
if (this.cleanupTimer) {
|
|
552
|
+
clearInterval(this.cleanupTimer);
|
|
553
|
+
this.cleanupTimer = null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Clear all active request timers
|
|
557
|
+
let clearedCount = 0;
|
|
558
|
+
for (const [requestId, tracked] of this.activeRequests.entries()) {
|
|
559
|
+
clearTimeout(tracked.timer);
|
|
560
|
+
clearedCount++;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Clear all tracked requests
|
|
564
|
+
this.activeRequests.clear();
|
|
565
|
+
|
|
566
|
+
// Clear handlers
|
|
567
|
+
const handlerCount = this.timeoutHandlers.length;
|
|
568
|
+
this.timeoutHandlers = [];
|
|
569
|
+
|
|
570
|
+
const stats = {
|
|
571
|
+
clearedRequests: clearedCount,
|
|
572
|
+
clearedHandlers: handlerCount,
|
|
573
|
+
finalMetrics: { ...this.metrics }
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
logger.info({
|
|
577
|
+
code: 'MC_TIMEOUT_SHUTDOWN_COMPLETE',
|
|
578
|
+
message: 'Timeout system shutdown complete',
|
|
579
|
+
...stats
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
return stats;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Get current timeout statistics and metrics
|
|
276
587
|
*
|
|
277
|
-
* @returns {Object} -
|
|
588
|
+
* @returns {Object} - Comprehensive timeout stats
|
|
278
589
|
*/
|
|
279
590
|
getStats() {
|
|
591
|
+
const now = Date.now();
|
|
592
|
+
const activeRequests = Array.from(this.activeRequests.entries()).map(([id, data]) => ({
|
|
593
|
+
requestId: id,
|
|
594
|
+
path: data.path,
|
|
595
|
+
method: data.method,
|
|
596
|
+
timeout: data.timeout,
|
|
597
|
+
elapsed: now - data.startTime,
|
|
598
|
+
remaining: Math.max(0, data.timeout - (now - data.startTime))
|
|
599
|
+
}));
|
|
600
|
+
|
|
280
601
|
return {
|
|
281
602
|
enabled: this.enabled,
|
|
282
603
|
globalTimeout: this.globalTimeout,
|
|
283
604
|
routeTimeouts: Array.from(this.routeTimeouts.entries()).map(([pattern, timeout]) => ({
|
|
284
|
-
pattern,
|
|
605
|
+
pattern: pattern.toString(),
|
|
285
606
|
timeout
|
|
286
607
|
})),
|
|
287
608
|
activeRequests: this.activeRequests.size,
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
609
|
+
maxActiveRequests: TIMEOUT_CONFIG.MAX_ACTIVE_REQUESTS,
|
|
610
|
+
requests: activeRequests,
|
|
611
|
+
|
|
612
|
+
// Metrics
|
|
613
|
+
metrics: {
|
|
614
|
+
totalRequests: this.metrics.totalRequests,
|
|
615
|
+
totalTimeouts: this.metrics.totalTimeouts,
|
|
616
|
+
timeoutRate: this.metrics.totalRequests > 0
|
|
617
|
+
? (this.metrics.totalTimeouts / this.metrics.totalRequests * 100).toFixed(2) + '%'
|
|
618
|
+
: '0%',
|
|
619
|
+
peakConcurrent: this.metrics.peakConcurrent,
|
|
620
|
+
averageResponseTime: this.metrics.totalRequests > 0
|
|
621
|
+
? Math.round(this.metrics.totalDuration / this.metrics.totalRequests)
|
|
622
|
+
: 0
|
|
623
|
+
},
|
|
624
|
+
|
|
625
|
+
// Configuration
|
|
626
|
+
config: {
|
|
627
|
+
minTimeout: TIMEOUT_CONFIG.MIN_TIMEOUT,
|
|
628
|
+
maxTimeout: TIMEOUT_CONFIG.MAX_TIMEOUT,
|
|
629
|
+
handlerTimeout: TIMEOUT_CONFIG.HANDLER_TIMEOUT,
|
|
630
|
+
cleanupInterval: TIMEOUT_CONFIG.CLEANUP_INTERVAL
|
|
631
|
+
}
|
|
296
632
|
};
|
|
297
633
|
}
|
|
298
634
|
|
|
299
635
|
/**
|
|
300
|
-
* Check if path matches pattern
|
|
636
|
+
* Check if path matches pattern with enhanced wildcard support
|
|
301
637
|
*
|
|
302
638
|
* @private
|
|
639
|
+
* @param {String} path - Request path
|
|
640
|
+
* @param {String|RegExp} pattern - Pattern to match
|
|
641
|
+
* @returns {Boolean} - True if path matches pattern
|
|
303
642
|
*/
|
|
304
643
|
_pathMatches(path, pattern) {
|
|
305
644
|
if (typeof pattern === 'string') {
|
|
306
|
-
// Normalize paths
|
|
645
|
+
// Normalize paths (remove leading/trailing slashes)
|
|
307
646
|
const normalizedPath = '/' + path.replace(/^\/|\/$/g, '');
|
|
308
647
|
const normalizedPattern = '/' + pattern.replace(/^\/|\/$/g, '');
|
|
309
648
|
|
|
310
|
-
//
|
|
649
|
+
// Exact match
|
|
650
|
+
if (normalizedPath === normalizedPattern) {
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Wildcard support: /api/* matches /api/users, /api/posts, etc.
|
|
311
655
|
if (normalizedPattern.endsWith('/*')) {
|
|
312
656
|
const prefix = normalizedPattern.slice(0, -2);
|
|
313
657
|
return normalizedPath === prefix || normalizedPath.startsWith(prefix + '/');
|
|
314
658
|
}
|
|
315
659
|
|
|
316
|
-
//
|
|
317
|
-
|
|
660
|
+
// Multiple wildcards: /api/*/posts matches /api/v1/posts, /api/v2/posts
|
|
661
|
+
if (normalizedPattern.includes('*')) {
|
|
662
|
+
const regexPattern = normalizedPattern
|
|
663
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
|
|
664
|
+
.replace(/\*/g, '[^/]+'); // * matches any segment
|
|
665
|
+
const regex = new RegExp('^' + regexPattern + '$');
|
|
666
|
+
return regex.test(normalizedPath);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Prefix match (for backwards compatibility)
|
|
670
|
+
return normalizedPath.startsWith(normalizedPattern + '/');
|
|
318
671
|
}
|
|
319
672
|
|
|
320
673
|
if (pattern instanceof RegExp) {
|
|
@@ -328,9 +681,12 @@ class MasterTimeout {
|
|
|
328
681
|
* Generate unique request ID
|
|
329
682
|
*
|
|
330
683
|
* @private
|
|
684
|
+
* @returns {String} - Unique request ID
|
|
331
685
|
*/
|
|
332
686
|
_generateRequestId() {
|
|
333
|
-
|
|
687
|
+
const timestamp = Date.now();
|
|
688
|
+
const random = Math.random().toString(36).substring(2, TIMEOUT_CONFIG.REQUEST_ID_LENGTH);
|
|
689
|
+
return `req_${timestamp}_${random}`;
|
|
334
690
|
}
|
|
335
691
|
}
|
|
336
692
|
|