@onlineapps/error-handler-core 1.1.16 → 2.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/CHANGELOG.md +48 -0
- package/README.md +46 -0
- package/package.json +12 -6
- package/src/CircuitBreaker.js +138 -13
- package/src/CircuitOpenError.js +30 -0
- package/src/CompensationHandler.js +9 -2
- package/src/DLQRouter.js +131 -24
- package/src/ErrorClassifier.js +17 -3
- package/src/ErrorHandlerCore.js +154 -47
- package/src/RetryHandler.js +88 -14
- package/src/UnifiedLogSchema.js +159 -38
- package/src/configRanges.js +92 -0
- package/src/index.js +4 -0
package/src/ErrorClassifier.js
CHANGED
|
@@ -31,6 +31,13 @@ const ErrorCodes = {
|
|
|
31
31
|
503: ErrorTypes.TRANSIENT,
|
|
32
32
|
504: ErrorTypes.TIMEOUT,
|
|
33
33
|
|
|
34
|
+
// Circuit breaker refusal (CircuitOpenError) -> TRANSIENT: the state heals
|
|
35
|
+
// itself once the breaker's resetTimeout elapses, so retrying is allowed.
|
|
36
|
+
// Pinned by code, never by message wording — the word "resetTimeout" in the
|
|
37
|
+
// fix hint would otherwise read as TIMEOUT via the patterns below.
|
|
38
|
+
// @see api/docs/governance/confirmations/circuit-open-classification.md
|
|
39
|
+
CIRCUIT_OPEN: ErrorTypes.TRANSIENT,
|
|
40
|
+
|
|
34
41
|
// Business errors
|
|
35
42
|
400: ErrorTypes.VALIDATION,
|
|
36
43
|
401: ErrorTypes.BUSINESS,
|
|
@@ -62,9 +69,16 @@ class ErrorClassifier {
|
|
|
62
69
|
return ErrorCodes[error.code];
|
|
63
70
|
}
|
|
64
71
|
|
|
65
|
-
// 3. Check HTTP status
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
// 3. Check HTTP status — `status`, the field the error contract names.
|
|
73
|
+
// A branded business error carries `code` (string) and `status` (number);
|
|
74
|
+
// `statusCode` was the Express-layer spelling and no producer feeds it into
|
|
75
|
+
// this classifier (the orchestrator's non-2xx error sets both names, and its
|
|
76
|
+
// own branch calls `classify` — a method the connector does not expose —
|
|
77
|
+
// so it never arrives here either). One name, no `status || statusCode`
|
|
78
|
+
// chain (`architecture-principles.md` §3).
|
|
79
|
+
// @see api/docs/biz/70-contracts/error-handling.md
|
|
80
|
+
if (error.status && ErrorCodes[error.status]) {
|
|
81
|
+
return ErrorCodes[error.status];
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
// 4. Check message patterns
|
package/src/ErrorHandlerCore.js
CHANGED
|
@@ -10,6 +10,8 @@ const CircuitBreakerManager = require('./CircuitBreaker');
|
|
|
10
10
|
const DLQRouter = require('./DLQRouter');
|
|
11
11
|
const CompensationHandler = require('./CompensationHandler');
|
|
12
12
|
const runtimeCfg = require('./config');
|
|
13
|
+
const { assertNumberInRange } = require('./configRanges');
|
|
14
|
+
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
13
15
|
|
|
14
16
|
class ErrorHandlerCore {
|
|
15
17
|
/**
|
|
@@ -20,28 +22,74 @@ class ErrorHandlerCore {
|
|
|
20
22
|
* @param {string} [config.serviceVersion] - Service version
|
|
21
23
|
* @param {string} [config.environment] - Environment
|
|
22
24
|
* @param {Object} config.monitoring - Monitoring instance (monitoring-core)
|
|
23
|
-
* @param {Object}
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* @param {
|
|
25
|
+
* @param {Object} config.logger - Logger with info/warn/error/debug (required).
|
|
26
|
+
* Used only where an error is deliberately swallowed — a monitoring outage
|
|
27
|
+
* here, a DLQ routing failure in DLQRouter — so it cannot vanish with it.
|
|
28
|
+
* @param {Object} [config.handling] - Error handling configuration. Every key
|
|
29
|
+
* below is optional and this class declares NO default for any of them: the
|
|
30
|
+
* value, its documented default and its accepted range belong to the
|
|
31
|
+
* component that reads the key, and are documented there once — repeating
|
|
32
|
+
* the numbers here made a second rail that d.173 already found lying.
|
|
33
|
+
* @param {number} [config.handling.maxRetries] - see `RetryHandler`
|
|
34
|
+
* @param {number} [config.handling.retryDelay] - see `RetryHandler`
|
|
35
|
+
* @param {number} [config.handling.retryMultiplier] - see `RetryHandler`
|
|
36
|
+
* @param {number} [config.handling.maxRetryDelay] - see `RetryHandler`
|
|
37
|
+
* @param {number} [config.handling.circuitTimeout] - `timeout`, see `CircuitBreakerManager`
|
|
38
|
+
* @param {number} [config.handling.errorThreshold] - see `CircuitBreakerManager`
|
|
39
|
+
* @param {number} [config.handling.resetTimeout] - see `CircuitBreakerManager`
|
|
27
40
|
* @param {boolean} [config.handling.circuitBreakerEnabled=true] - Enable circuit breaker
|
|
28
41
|
* @param {boolean} [config.handling.dlqEnabled=true] - Enable DLQ routing
|
|
29
42
|
* @param {Object} [config.handling.mqClient] - MQ client for DLQ
|
|
30
43
|
* @param {boolean} [config.handling.compensationEnabled=true] - Enable compensation
|
|
44
|
+
* @throws {Error} If a handling value is outside the range its component declares
|
|
31
45
|
*/
|
|
32
46
|
constructor(config) {
|
|
33
47
|
if (!config.serviceName) {
|
|
34
|
-
throw new Error(
|
|
48
|
+
throw new Error(
|
|
49
|
+
'[ErrorHandlerCore] Missing required config key - serviceName is required. '
|
|
50
|
+
+ 'Fix: pass config.serviceName; it labels every log line this handler writes.'
|
|
51
|
+
);
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
if (!config.monitoring) {
|
|
38
|
-
throw new Error(
|
|
55
|
+
throw new Error(
|
|
56
|
+
'[ErrorHandlerCore] Missing dependency - monitoring instance is required (monitoring-core). '
|
|
57
|
+
+ 'Fix: pass config.monitoring; without it no error this handler sees reaches a log.'
|
|
58
|
+
);
|
|
39
59
|
}
|
|
40
|
-
|
|
60
|
+
|
|
61
|
+
// A monitoring instance that implements neither shape can never carry a log
|
|
62
|
+
// line. It used to be discovered at the first logError(), which then dumped
|
|
63
|
+
// JSON to stdout; the constructor is where it belongs (§4 fail-fast, §3 no
|
|
64
|
+
// fallback). Both shapes stay accepted — monitoring-core exposes
|
|
65
|
+
// `.logger.error()`, the conn-base-monitoring wrapper a bare `.error()`.
|
|
66
|
+
const monitoringCanLog =
|
|
67
|
+
(config.monitoring.logger && typeof config.monitoring.logger.error === 'function')
|
|
68
|
+
|| typeof config.monitoring.error === 'function';
|
|
69
|
+
if (!monitoringCanLog) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'[ErrorHandlerCore] monitoring instance cannot log - Expected: monitoring.logger.error() '
|
|
72
|
+
+ '(monitoring-core) or monitoring.error() (conn-base-monitoring) as a function. '
|
|
73
|
+
+ 'Fix: pass an initialised monitoring instance.'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.logger = assertLogger(
|
|
78
|
+
'ErrorHandlerCore',
|
|
79
|
+
config.logger,
|
|
80
|
+
'a monitoring outage this handler deliberately swallows still reaches the structured log'
|
|
81
|
+
);
|
|
82
|
+
|
|
41
83
|
this.serviceName = config.serviceName;
|
|
84
|
+
// One rail, the one d.178 introduced: `runtimeCfg.resolve` already prefers
|
|
85
|
+
// the explicit value over the env variable and the env variable over the
|
|
86
|
+
// declared default. The `config.x || resolved.x` that stood here re-asked the
|
|
87
|
+
// same question a second time and, being `||`, would have thrown away an
|
|
88
|
+
// explicit falsy answer — `.claude/rules/change-discipline.md`
|
|
89
|
+
// § One rail per concern.
|
|
42
90
|
const resolved = runtimeCfg.resolve(config);
|
|
43
|
-
this.serviceVersion =
|
|
44
|
-
this.environment =
|
|
91
|
+
this.serviceVersion = resolved.serviceVersion;
|
|
92
|
+
this.environment = resolved.environment;
|
|
45
93
|
this.monitoring = config.monitoring;
|
|
46
94
|
|
|
47
95
|
const handling = config.handling || {};
|
|
@@ -49,21 +97,27 @@ class ErrorHandlerCore {
|
|
|
49
97
|
// Initialize components
|
|
50
98
|
this.classifier = new ErrorClassifier();
|
|
51
99
|
this.logSchema = new UnifiedLogSchema();
|
|
100
|
+
// Values are forwarded as given — `undefined` included. The default for
|
|
101
|
+
// each key is declared once, by the component that owns it (RetryHandler,
|
|
102
|
+
// CircuitBreakerManager, DLQRouter); re-stating the same numbers here made
|
|
103
|
+
// two rails for one fact and, being `||`, turned an explicit 0 into the
|
|
104
|
+
// default before the owning class ever saw it.
|
|
52
105
|
this.retryHandler = new RetryHandler({
|
|
53
|
-
maxRetries: handling.maxRetries
|
|
54
|
-
retryDelay: handling.retryDelay
|
|
55
|
-
retryMultiplier: handling.retryMultiplier
|
|
56
|
-
maxRetryDelay: handling.maxRetryDelay
|
|
106
|
+
maxRetries: handling.maxRetries,
|
|
107
|
+
retryDelay: handling.retryDelay,
|
|
108
|
+
retryMultiplier: handling.retryMultiplier,
|
|
109
|
+
maxRetryDelay: handling.maxRetryDelay
|
|
57
110
|
});
|
|
58
111
|
this.circuitBreaker = new CircuitBreakerManager({
|
|
59
112
|
circuitBreakerEnabled: handling.circuitBreakerEnabled !== false,
|
|
60
|
-
timeout: handling.circuitTimeout
|
|
61
|
-
errorThreshold: handling.errorThreshold
|
|
62
|
-
resetTimeout: handling.resetTimeout
|
|
113
|
+
timeout: handling.circuitTimeout,
|
|
114
|
+
errorThreshold: handling.errorThreshold,
|
|
115
|
+
resetTimeout: handling.resetTimeout
|
|
63
116
|
});
|
|
64
117
|
this.dlqRouter = new DLQRouter({
|
|
65
118
|
dlqEnabled: handling.dlqEnabled !== false,
|
|
66
|
-
mqClient: handling.mqClient
|
|
119
|
+
mqClient: handling.mqClient,
|
|
120
|
+
logger: this.logger
|
|
67
121
|
});
|
|
68
122
|
this.compensation = new CompensationHandler({
|
|
69
123
|
compensationEnabled: handling.compensationEnabled !== false
|
|
@@ -72,7 +126,8 @@ class ErrorHandlerCore {
|
|
|
72
126
|
|
|
73
127
|
/**
|
|
74
128
|
* Log error using monitoring connector
|
|
75
|
-
*
|
|
129
|
+
* Writes through the monitoring instance; a monitoring outage falls to the
|
|
130
|
+
* injected logger rather than to stdout.
|
|
76
131
|
*
|
|
77
132
|
* @param {Object} errorData - Error data
|
|
78
133
|
* @param {string} errorData.moduleName - Module name
|
|
@@ -100,34 +155,33 @@ class ErrorHandlerCore {
|
|
|
100
155
|
handling: {} // Will be filled by handleError
|
|
101
156
|
});
|
|
102
157
|
|
|
103
|
-
// Log via monitoring connector
|
|
104
|
-
//
|
|
158
|
+
// Log via monitoring connector. Which of the two shapes is present was settled
|
|
159
|
+
// in the constructor, so there is no third branch left to fall back to.
|
|
105
160
|
const logMessage = `[${moduleName}] ${operation} failed: ${error.message}`;
|
|
106
|
-
|
|
161
|
+
|
|
107
162
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (this.monitoring.logger && this.monitoring.logger.error) {
|
|
163
|
+
if (this.monitoring.logger && typeof this.monitoring.logger.error === 'function') {
|
|
164
|
+
// monitoring-core: instance with a logger property
|
|
111
165
|
this.monitoring.logger.error(logMessage, {
|
|
112
166
|
...errorLog,
|
|
113
167
|
severity: errorLog.metadata.severity
|
|
114
168
|
});
|
|
115
|
-
} else
|
|
116
|
-
//
|
|
169
|
+
} else {
|
|
170
|
+
// conn-base-monitoring wrapper: direct error method
|
|
117
171
|
this.monitoring.error(logMessage, {
|
|
118
172
|
...errorLog,
|
|
119
173
|
severity: errorLog.metadata.severity
|
|
120
174
|
});
|
|
121
|
-
} else {
|
|
122
|
-
// Last resort: console
|
|
123
|
-
console.error(JSON.stringify(errorLog, null, 2));
|
|
124
175
|
}
|
|
125
176
|
} catch (logError) {
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
177
|
+
// Swallowed on purpose: a monitoring outage must not break error handling.
|
|
178
|
+
// The injected logger is the only reason the lost entry stays visible.
|
|
179
|
+
this.logger.error('[ErrorHandlerCore] Monitoring log failed', {
|
|
180
|
+
reason: logError.message,
|
|
181
|
+
errorLog
|
|
182
|
+
});
|
|
129
183
|
}
|
|
130
|
-
|
|
184
|
+
|
|
131
185
|
return errorLog;
|
|
132
186
|
}
|
|
133
187
|
|
|
@@ -139,9 +193,12 @@ class ErrorHandlerCore {
|
|
|
139
193
|
* @param {string} errorData.operation - Operation name
|
|
140
194
|
* @param {Error} errorData.error - Error object
|
|
141
195
|
* @param {Object} [errorData.context] - Additional context
|
|
142
|
-
* @param {number} [errorData.context.retryCount] -
|
|
143
|
-
*
|
|
196
|
+
* @param {number} [errorData.context.retryCount=0] - Retries already made
|
|
197
|
+
* before this failure; integer >= 0
|
|
198
|
+
* @param {number} [errorData.context.maxRetries] - Attempt budget for this
|
|
199
|
+
* call, see `RetryHandler`; integer >= 1
|
|
144
200
|
* @returns {Promise<Object>} Handling result
|
|
201
|
+
* @throws {Error} If context.retryCount or context.maxRetries is outside its range
|
|
145
202
|
*/
|
|
146
203
|
async handleError(errorData) {
|
|
147
204
|
const { moduleName, operation, error, context = {} } = errorData;
|
|
@@ -167,8 +224,8 @@ class ErrorHandlerCore {
|
|
|
167
224
|
errorType,
|
|
168
225
|
shouldRetry: action.shouldRetry || false,
|
|
169
226
|
retryDelay: action.retryDelay || null,
|
|
170
|
-
retryCount: context
|
|
171
|
-
maxRetries:
|
|
227
|
+
retryCount: this._resolveRetryCount(context),
|
|
228
|
+
maxRetries: this._resolveMaxRetries(context)
|
|
172
229
|
};
|
|
173
230
|
|
|
174
231
|
// 5. Execute action
|
|
@@ -193,20 +250,69 @@ class ErrorHandlerCore {
|
|
|
193
250
|
}
|
|
194
251
|
|
|
195
252
|
/**
|
|
196
|
-
*
|
|
253
|
+
* Attempt budget for ONE call: the caller's `context.maxRetries` when they
|
|
254
|
+
* passed one, otherwise the handler's own. `??`, not `||`, and validated by
|
|
255
|
+
* the component that owns the key — a 0 from the caller used to fall through
|
|
256
|
+
* to the instance value, so a caller asking for no retries silently got three.
|
|
257
|
+
*
|
|
258
|
+
* @param {Object} context - Call context
|
|
259
|
+
* @returns {number} Accepted attempt budget
|
|
260
|
+
* @throws {Error} If context.maxRetries is outside its range
|
|
261
|
+
* @private
|
|
262
|
+
*/
|
|
263
|
+
_resolveMaxRetries(context) {
|
|
264
|
+
const maxRetries = context.maxRetries ?? this.retryHandler.maxRetries;
|
|
265
|
+
RetryHandler.assertMaxRetries('ErrorHandlerCore', 'context.maxRetries', maxRetries);
|
|
266
|
+
return maxRetries;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Retries already made before the failure being handled. It decides whether
|
|
271
|
+
* another attempt is left, so it is checked like any other number the caller
|
|
272
|
+
* supplies (§4 fail-fast); `|| 0` used to read every nonsense value as "none
|
|
273
|
+
* yet" and started the budget over.
|
|
274
|
+
*
|
|
275
|
+
* @param {Object} context - Call context
|
|
276
|
+
* @returns {number} Accepted retry count
|
|
277
|
+
* @throws {Error} If context.retryCount is not an integer >= 0
|
|
278
|
+
* @private
|
|
279
|
+
*/
|
|
280
|
+
_resolveRetryCount(context) {
|
|
281
|
+
const retryCount = context.retryCount ?? 0;
|
|
282
|
+
assertNumberInRange('ErrorHandlerCore', 'context.retryCount', retryCount, {
|
|
283
|
+
integer: true,
|
|
284
|
+
min: 0,
|
|
285
|
+
expected: 'an integer >= 0 (the number of retries already made)'
|
|
286
|
+
});
|
|
287
|
+
return retryCount;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Determine action based on error type.
|
|
292
|
+
*
|
|
293
|
+
* `maxRetries` is a budget of ATTEMPTS — the meaning `RetryHandler` documents
|
|
294
|
+
* ("Max attempts"), the meaning its range message states ("the number of
|
|
295
|
+
* attempts") and the meaning `ErrorClassifier.shouldRetry` uses. This method
|
|
296
|
+
* used to compare `retryCount < maxRetries`, i.e. read the same key as a
|
|
297
|
+
* budget of RETRIES, one more than the loop in `RetryHandler.executeWithRetry`
|
|
298
|
+
* ever ran: `maxRetries: 3` meant three calls there and four here.
|
|
299
|
+
*
|
|
197
300
|
* @private
|
|
198
301
|
*/
|
|
199
302
|
_determineAction(errorType, context) {
|
|
200
|
-
const retryCount = context
|
|
201
|
-
const
|
|
202
|
-
|
|
303
|
+
const retryCount = this._resolveRetryCount(context);
|
|
304
|
+
const maxAttempts = this._resolveMaxRetries(context);
|
|
305
|
+
// The failure being handled IS an attempt, so the attempts made so far are
|
|
306
|
+
// the retries plus the original call.
|
|
307
|
+
const attemptsMade = retryCount + 1;
|
|
308
|
+
|
|
203
309
|
switch (errorType) {
|
|
204
310
|
case ErrorTypes.TRANSIENT:
|
|
205
311
|
case ErrorTypes.TIMEOUT:
|
|
206
312
|
case ErrorTypes.RATE_LIMIT:
|
|
207
|
-
// Retry
|
|
208
|
-
if (
|
|
209
|
-
const delay = this.retryHandler.calculateBackoff(
|
|
313
|
+
// Retry while the attempt budget still has room
|
|
314
|
+
if (attemptsMade < maxAttempts) {
|
|
315
|
+
const delay = this.retryHandler.calculateBackoff(attemptsMade);
|
|
210
316
|
return {
|
|
211
317
|
type: 'retry',
|
|
212
318
|
shouldRetry: true,
|
|
@@ -243,9 +349,10 @@ class ErrorHandlerCore {
|
|
|
243
349
|
*/
|
|
244
350
|
async executeWithRetry(options) {
|
|
245
351
|
const { moduleName, operation, fn, context = {} } = options;
|
|
246
|
-
|
|
352
|
+
const maxRetries = this._resolveMaxRetries(context);
|
|
353
|
+
|
|
247
354
|
return await this.retryHandler.executeWithRetry(fn, {
|
|
248
|
-
maxRetries
|
|
355
|
+
maxRetries,
|
|
249
356
|
onRetry: async (error, attempt, delay) => {
|
|
250
357
|
// Log each retry attempt
|
|
251
358
|
await this.logError({
|
|
@@ -255,7 +362,7 @@ class ErrorHandlerCore {
|
|
|
255
362
|
context: {
|
|
256
363
|
...context,
|
|
257
364
|
retryCount: attempt,
|
|
258
|
-
maxRetries
|
|
365
|
+
maxRetries
|
|
259
366
|
}
|
|
260
367
|
});
|
|
261
368
|
}
|
package/src/RetryHandler.js
CHANGED
|
@@ -3,14 +3,84 @@
|
|
|
3
3
|
* Implements exponential backoff retry logic
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const { assertNumberInRange } = require('./configRanges');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The retry budget is a number of ATTEMPTS, so 1 is the smallest meaningful
|
|
10
|
+
* value: `executeWithRetry` loops `attempt = 1 .. maxRetries`. Spelled once,
|
|
11
|
+
* here, because ErrorHandlerCore validates the same key when a caller overrides
|
|
12
|
+
* it per call — see `RetryHandler.assertMaxRetries`.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_RETRIES_RULE = {
|
|
15
|
+
integer: true,
|
|
16
|
+
min: 1,
|
|
17
|
+
expected: 'an integer >= 1 (the number of attempts)'
|
|
18
|
+
};
|
|
19
|
+
|
|
6
20
|
class RetryHandler {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Every key is optional and its default is declared HERE, once — this class
|
|
23
|
+
* owns them. Written as destructuring defaults, not `||`: a default may only
|
|
24
|
+
* stand in for a MISSING key, never overrule an explicit 0 (a caller asking
|
|
25
|
+
* for no delay got 1000 ms instead). See
|
|
26
|
+
* `.claude/rules/architecture-principles.md` §3.
|
|
27
|
+
*
|
|
28
|
+
* A value outside its documented range is refused here rather than carried
|
|
29
|
+
* into the loop: `maxRetries: 0` used to be swallowed by `||`, and once d.173
|
|
30
|
+
* let it through it produced a loop running zero times and a `throw undefined`
|
|
31
|
+
* (`.claude/rules/architecture-principles.md` §4).
|
|
32
|
+
*
|
|
33
|
+
* @param {Object} [config] - Retry configuration
|
|
34
|
+
* @param {number} [config.maxRetries=3] - Max attempts; integer >= 1
|
|
35
|
+
* @param {number} [config.retryDelay=1000] - Initial delay in ms; >= 0
|
|
36
|
+
* @param {number} [config.retryMultiplier=2] - Backoff multiplier; >= 1
|
|
37
|
+
* @param {number} [config.maxRetryDelay=30000] - Delay ceiling in ms; >= retryDelay
|
|
38
|
+
* @throws {Error} If any value is not a finite number inside its range
|
|
39
|
+
*/
|
|
40
|
+
constructor({
|
|
41
|
+
maxRetries = 3,
|
|
42
|
+
retryDelay = 1000,
|
|
43
|
+
retryMultiplier = 2,
|
|
44
|
+
maxRetryDelay = 30000
|
|
45
|
+
} = {}) {
|
|
46
|
+
RetryHandler.assertMaxRetries('RetryHandler', 'maxRetries', maxRetries);
|
|
47
|
+
assertNumberInRange('RetryHandler', 'retryDelay', retryDelay, {
|
|
48
|
+
min: 0,
|
|
49
|
+
expected: 'a number >= 0 (milliseconds)'
|
|
50
|
+
});
|
|
51
|
+
assertNumberInRange('RetryHandler', 'retryMultiplier', retryMultiplier, {
|
|
52
|
+
min: 1,
|
|
53
|
+
expected: 'a number >= 1'
|
|
54
|
+
});
|
|
55
|
+
// Checked against retryDelay, not against 0: a ceiling below the floor makes
|
|
56
|
+
// calculateBackoff() return the ceiling on every attempt, so the initial
|
|
57
|
+
// delay the caller asked for would never be waited even once.
|
|
58
|
+
assertNumberInRange('RetryHandler', 'maxRetryDelay', maxRetryDelay, {
|
|
59
|
+
min: retryDelay,
|
|
60
|
+
expected: `a number >= retryDelay (${retryDelay})`
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
this.maxRetries = maxRetries;
|
|
64
|
+
this.retryDelay = retryDelay;
|
|
65
|
+
this.retryMultiplier = retryMultiplier;
|
|
66
|
+
this.maxRetryDelay = maxRetryDelay;
|
|
12
67
|
}
|
|
13
|
-
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Apply the retry-budget rule to a value, wherever it was configured: this
|
|
71
|
+
* constructor, a per-call `options.maxRetries`, or ErrorHandlerCore's
|
|
72
|
+
* `context.maxRetries`. One rule, one place; the caller supplies the header
|
|
73
|
+
* and the key so the message names what the caller actually wrote.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} context - Component raising the refusal
|
|
76
|
+
* @param {string} key - Key as the caller spells it
|
|
77
|
+
* @param {*} value - Value as received
|
|
78
|
+
* @throws {Error} If the value is not an integer >= 1
|
|
79
|
+
*/
|
|
80
|
+
static assertMaxRetries(context, key, value) {
|
|
81
|
+
assertNumberInRange(context, key, value, MAX_RETRIES_RULE);
|
|
82
|
+
}
|
|
83
|
+
|
|
14
84
|
/**
|
|
15
85
|
* Calculate exponential backoff delay
|
|
16
86
|
*
|
|
@@ -35,21 +105,27 @@ class RetryHandler {
|
|
|
35
105
|
*
|
|
36
106
|
* @param {Function} fn - Async function to execute
|
|
37
107
|
* @param {Object} options - Retry options
|
|
38
|
-
* @param {number} [options.maxRetries] - Override max
|
|
108
|
+
* @param {number} [options.maxRetries] - Override max attempts; integer >= 1
|
|
39
109
|
* @param {Function} [options.onRetry] - Callback on retry (error, attempt, delay)
|
|
40
110
|
* @returns {Promise<*>} Function result
|
|
41
111
|
* @throws {Error} Last error if all retries fail
|
|
112
|
+
* @throws {Error} If options.maxRetries is outside its range — before fn runs
|
|
42
113
|
*/
|
|
43
114
|
async executeWithRetry(fn, options = {}) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
115
|
+
// `??`, not `||`: the override is taken when the caller passed one, and an
|
|
116
|
+
// out-of-range override is refused rather than silently replaced by the
|
|
117
|
+
// instance value. The check also covers the instance value, which the
|
|
118
|
+
// constructor has already accepted, so it can only ever fire for an override.
|
|
119
|
+
const maxAttempts = options.maxRetries ?? this.maxRetries;
|
|
120
|
+
RetryHandler.assertMaxRetries('RetryHandler', 'options.maxRetries', maxAttempts);
|
|
121
|
+
|
|
122
|
+
// No `lastError` and no throw after the loop: with maxAttempts >= 1 the loop
|
|
123
|
+
// always either returns or throws, and the trailing `throw lastError` it used
|
|
124
|
+
// to end with handed the caller `undefined` for maxAttempts 0.
|
|
47
125
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
48
126
|
try {
|
|
49
127
|
return await fn();
|
|
50
128
|
} catch (error) {
|
|
51
|
-
lastError = error;
|
|
52
|
-
|
|
53
129
|
// If last attempt, throw
|
|
54
130
|
if (attempt === maxAttempts) {
|
|
55
131
|
throw error;
|
|
@@ -65,8 +141,6 @@ class RetryHandler {
|
|
|
65
141
|
await this.sleep(delay);
|
|
66
142
|
}
|
|
67
143
|
}
|
|
68
|
-
|
|
69
|
-
throw lastError;
|
|
70
144
|
}
|
|
71
145
|
}
|
|
72
146
|
|