@onlineapps/error-handler-core 1.1.15 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- if (error.statusCode && ErrorCodes[error.statusCode]) {
67
- return ErrorCodes[error.statusCode];
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
@@ -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} [config.handling] - Error handling configuration
24
- * @param {number} [config.handling.maxRetries=3] - Max retry attempts
25
- * @param {number} [config.handling.retryDelay=1000] - Initial retry delay
26
- * @param {number} [config.handling.retryMultiplier=2] - Backoff multiplier
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('serviceName is required');
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('monitoring instance is required (monitoring-core)');
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 = config.serviceVersion || resolved.serviceVersion;
44
- this.environment = config.environment || resolved.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 || 3,
54
- retryDelay: handling.retryDelay || 1000,
55
- retryMultiplier: handling.retryMultiplier || 2,
56
- maxRetryDelay: handling.maxRetryDelay || 30000
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 || 10000,
61
- errorThreshold: handling.errorThreshold || 50,
62
- resetTimeout: handling.resetTimeout || 30000
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 || null
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
- * Always works: console (via monitoring) + RabbitMQ (if available)
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
- // Monitoring connector has fallback to console if not initialized
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
- // Use monitoring logger (monitoring-core returns instance with logger property)
109
- // logger.error() always logs to console, optionally to RabbitMQ
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 if (this.monitoring.error) {
116
- // Fallback: direct error method (conn-base-monitoring wrapper)
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
- // If monitoring fails, fallback to console
127
- console.error('[ErrorHandlerCore] Monitoring log failed, using console:', logError.message);
128
- console.error(JSON.stringify(errorLog, null, 2));
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] - Current retry count
143
- * @param {number} [errorData.context.maxRetries] - Max retries
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.retryCount || 0,
171
- maxRetries: context.maxRetries || this.retryHandler.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
- * Determine action based on error type
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.retryCount || 0;
201
- const maxRetries = context.maxRetries || this.retryHandler.maxRetries;
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 if under max retries
208
- if (retryCount < maxRetries) {
209
- const delay = this.retryHandler.calculateBackoff(retryCount + 1);
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: context.maxRetries || this.retryHandler.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: context.maxRetries || this.retryHandler.maxRetries
365
+ maxRetries
259
366
  }
260
367
  });
261
368
  }
@@ -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
- constructor(config = {}) {
8
- this.maxRetries = config.maxRetries || 3;
9
- this.retryDelay = config.retryDelay || 1000;
10
- this.retryMultiplier = config.retryMultiplier || 2;
11
- this.maxRetryDelay = config.maxRetryDelay || 30000;
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 retries
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
- const maxAttempts = options.maxRetries || this.maxRetries;
45
- let lastError;
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