@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.
@@ -5,22 +5,64 @@
5
5
 
6
6
  const os = require('os');
7
7
  const runtimeCfg = require('./config');
8
+ const { ErrorTypes } = require('./ErrorClassifier');
9
+ const { describeValue } = require('./configRanges');
10
+
11
+ /**
12
+ * Severity per error category.
13
+ *
14
+ * The set of categories has ONE owner, the classifier (`ErrorClassifier.js`,
15
+ * `ErrorTypes`), so this map keys off that declaration rather than repeating the
16
+ * names — a literal list here would be a second rail
17
+ * (`.claude/rules/change-discipline.md` § One rail per concern), and the
18
+ * `|| 'error'` that closed it made the drift silent: a category added at the
19
+ * owner and forgotten here was filed with a severity nobody chose.
20
+ */
21
+ const SEVERITY_BY_TYPE = {
22
+ [ErrorTypes.TRANSIENT]: 'error',
23
+ [ErrorTypes.BUSINESS]: 'error',
24
+ [ErrorTypes.FATAL]: 'fatal',
25
+ [ErrorTypes.VALIDATION]: 'warn',
26
+ [ErrorTypes.TIMEOUT]: 'error',
27
+ [ErrorTypes.RATE_LIMIT]: 'warn',
28
+ [ErrorTypes.UNKNOWN]: 'error'
29
+ };
30
+
31
+ // Checked at load, not at log time: a category the owner declares and this file
32
+ // does not answer for is a gap in the map, and the place to hear about it is
33
+ // `require()`, once, rather than in one log line out of thousands
34
+ // (`.claude/rules/architecture-principles.md` §4 fail-fast).
35
+ for (const type of Object.values(ErrorTypes)) {
36
+ if (SEVERITY_BY_TYPE[type] === undefined) {
37
+ throw new Error(
38
+ `[UnifiedLogSchema] Missing severity for error type - "${type}" is declared by ErrorClassifier `
39
+ + 'but has no severity here, and a severity is never guessed. '
40
+ + `Fix: give "${type}" a severity in SEVERITY_BY_TYPE (UnifiedLogSchema.js).`
41
+ );
42
+ }
43
+ }
44
+
45
+ const ACCEPTED_TYPES = Object.values(ErrorTypes);
8
46
 
9
47
  class UnifiedLogSchema {
10
48
  /**
11
49
  * Create unified error log entry
12
50
  *
13
51
  * @param {Object} params - Error data
14
- * @param {string} params.moduleName - Module/component name
15
- * @param {string} params.operation - Operation that failed
16
- * @param {Error} params.error - Error object
52
+ * @param {string} params.moduleName - Module/component name (required; never invented)
53
+ * @param {string} params.operation - Operation that failed (required; never invented)
54
+ * @param {Error} params.error - Error object (required; must be an Error)
17
55
  * @param {Object} params.context - Additional context
18
- * @param {string} params.serviceName - Service name
19
- * @param {string} params.serviceVersion - Service version
20
- * @param {string} params.environment - Environment (production, staging, etc.)
21
- * @param {string} params.errorType - Classified error type
56
+ * @param {string} params.serviceName - Service name (required; never invented)
57
+ * @param {string} [params.serviceVersion] - Service version; falls back to the
58
+ * declared rail `SERVICE_VERSION` → `defaults.js`, never to a literal here
59
+ * @param {string} [params.environment] - Environment; same declared rail
60
+ * @param {string} params.errorType - Classified error type (required; the
61
+ * classifier's verdict, never invented here)
22
62
  * @param {Object} params.handling - Handling decision
23
63
  * @returns {Object} Unified error log entry
64
+ * @throws {Error} If serviceName, moduleName, operation or errorType is missing
65
+ * or not a non-empty string, or if error is not an Error instance
24
66
  */
25
67
  create(params) {
26
68
  const {
@@ -34,49 +76,132 @@ class UnifiedLogSchema {
34
76
  errorType,
35
77
  handling = {}
36
78
  } = params;
37
-
79
+
80
+ // The service that failed to introduce itself used to be renamed
81
+ // `unknown-service`, and downstream that reads exactly like a real name. An
82
+ // identity is never invented (owner, `api/docs/governance/confirmations/
83
+ // dlq-purge.md` 002); `defaults.js` says the same in words — service
84
+ // identity is the caller's to provide.
85
+ if (typeof serviceName !== 'string' || serviceName.trim() === '') {
86
+ throw new Error(
87
+ '[UnifiedLogSchema] Missing required field - "serviceName" must be a non-empty string; '
88
+ + 'it names the service every entry is filed under and is never invented. '
89
+ + 'Fix: pass params.serviceName (ErrorHandlerCore takes it from config.serviceName).'
90
+ );
91
+ }
92
+
93
+ // The module and the operation are the caller's input too, for exactly the
94
+ // reason above: `|| 'unknown-module'` / `|| 'unknown-operation'` filed the
95
+ // entry under a module and an operation of those names, and downstream that
96
+ // reads like a real one. The JSDoc has had them as required here, in
97
+ // `ErrorHandlerCore.logError` and in the `conn-infra-error-handler` wrapper.
98
+ if (typeof moduleName !== 'string' || moduleName.trim() === '') {
99
+ throw new Error(
100
+ '[UnifiedLogSchema] Missing required field - "moduleName" must be a non-empty string naming the '
101
+ + 'module that failed; it is the caller\'s to provide and is never invented. '
102
+ + 'Fix: pass params.moduleName (ErrorHandlerCore takes it from errorData.moduleName).'
103
+ );
104
+ }
105
+
106
+ if (typeof operation !== 'string' || operation.trim() === '') {
107
+ throw new Error(
108
+ '[UnifiedLogSchema] Missing required field - "operation" must be a non-empty string naming the '
109
+ + 'operation that failed; it is the caller\'s to provide and is never invented. '
110
+ + 'Fix: pass params.operation (ErrorHandlerCore takes it from errorData.operation).'
111
+ );
112
+ }
113
+
114
+ // An Error always carries `message` and `stack`, so `|| 'Unknown error'`
115
+ // could only ever fire for something that is NOT an error — and it turned
116
+ // that caller defect into an entry reading like a real failure with a real
117
+ // message. What arrives is either an Error or a refusal.
118
+ if (!(error instanceof Error)) {
119
+ throw new Error(
120
+ '[UnifiedLogSchema] Missing required field - "error" must be an Error instance carrying the '
121
+ + 'message and the stack of the failure; a substitute message would read downstream like a real one. '
122
+ + 'Fix: pass the caught Error itself (wrap a non-Error rejection in one at the point it is caught).'
123
+ );
124
+ }
125
+
126
+ // The category is the classifier's word (`ErrorClassifier.classify()` always
127
+ // returns one, `UNKNOWN` included). `|| 'UNKNOWN'` let the schema speak it
128
+ // instead, so an entry could claim a classification nobody made.
129
+ //
130
+ // One rule, one refusal: the field is acceptable iff it is a category the
131
+ // classifier declares, so a missing value and an invented one are the same
132
+ // class of refusal — the stance `configRanges.js` already takes for a wrong
133
+ // type versus a value out of range.
134
+ if (!ACCEPTED_TYPES.includes(errorType)) {
135
+ throw new Error(
136
+ '[UnifiedLogSchema] Invalid error type - "errorType" must be one of the categories '
137
+ + `ErrorClassifier declares (${ACCEPTED_TYPES.join(', ')}), got ${describeValue(errorType)}. `
138
+ + 'Fix: pass params.errorType from ErrorClassifier.classify(error).'
139
+ );
140
+ }
141
+
38
142
  // Extract error details
39
143
  const errorDetails = {
40
- message: error?.message || 'Unknown error',
41
- code: error?.code || null,
42
- type: errorType || 'UNKNOWN',
43
- stack: error?.stack || null
144
+ message: error.message,
145
+ // `??`, not `||`: a code that is present but falsy is a value, and the
146
+ // absence of one is `null`. `||` merged the two.
147
+ code: error.code ?? null,
148
+ type: errorType,
149
+ stack: error.stack ?? null
44
150
  };
45
151
 
46
152
  // Build unified log entry
47
153
  return {
48
154
  // Core identification
49
155
  timestamp: new Date().toISOString(),
50
- serviceName: serviceName || 'unknown-service',
51
- moduleName: moduleName || 'unknown-module',
52
- operation: operation || 'unknown-operation',
53
-
156
+ serviceName,
157
+ moduleName,
158
+ operation,
159
+
54
160
  // Error details
55
161
  error: errorDetails,
56
162
 
57
163
  // Context
58
164
  context: {
59
- workflowId: context.workflowId || null,
60
- userId: context.userId || null,
61
- requestId: context.requestId || null,
165
+ // `??` throughout this file wherever `null` states an absence: a present
166
+ // falsy value is a value. (The `...context` below re-applies whatever the
167
+ // caller passed, so these three only ever declare the key for an ABSENT
168
+ // one — the operator makes no difference here, the single shape does.)
169
+ workflowId: context.workflowId ?? null,
170
+ userId: context.userId ?? null,
171
+ requestId: context.requestId ?? null,
62
172
  ...context
63
173
  },
64
174
 
65
175
  // Handling decision
66
176
  handling: {
67
- action: handling.action || 'throw',
68
- retryCount: handling.retryCount || null,
69
- maxRetries: handling.maxRetries || null,
70
- retryDelay: handling.retryDelay || null,
71
- circuitBreakerState: handling.circuitBreakerState || null,
72
- circuitName: handling.circuitName || null
177
+ // `null`, not `'throw'`: `logError()` deliberately passes `handling: {}`
178
+ // because the decision has not been made yet at that point. Writing
179
+ // `'throw'` there recorded a decision nobody took, and it read exactly
180
+ // like one that was.
181
+ action: handling.action ?? null,
182
+ // `??`: zero retries so far is a measurement, not a missing value. `||`
183
+ // turned it into `null`, so "nothing has been retried yet" and "we do not
184
+ // know" became the same entry — the defect d.173 removed in configuration.
185
+ retryCount: handling.retryCount ?? null,
186
+ maxRetries: handling.maxRetries ?? null,
187
+ // Same as retryCount: `retryDelay: 0` is a documented, valid value
188
+ // (`RetryHandler` accepts `retryDelay >= 0`), and `||` filed it as "we do
189
+ // not know".
190
+ retryDelay: handling.retryDelay ?? null,
191
+ circuitBreakerState: handling.circuitBreakerState ?? null,
192
+ circuitName: handling.circuitName ?? null
73
193
  },
74
194
 
75
195
  // Metadata
76
196
  metadata: {
77
197
  severity: this._determineSeverity(errorType),
78
198
  environment: runtimeCfg.get('environment', environment),
79
- version: serviceVersion || '0.0.0',
199
+ // The version has a declared default and an env rail of its own
200
+ // (`defaults.js` + `config.js`, the same one `environment` uses one line
201
+ // up). The literal that stood here was a second copy of that default —
202
+ // and it ignored SERVICE_VERSION, so a service that set the variable
203
+ // still reported 0.0.0.
204
+ version: runtimeCfg.get('serviceVersion', serviceVersion),
80
205
  hostname: os.hostname(),
81
206
  pid: process.pid
82
207
  }
@@ -84,21 +209,17 @@ class UnifiedLogSchema {
84
209
  }
85
210
 
86
211
  /**
87
- * Determine severity from error type
212
+ * Severity of a category. No fallback and no branch: `create()` has already
213
+ * refused anything that is not a declared category, and the load-time check
214
+ * above has already refused a declared category with no severity — so the
215
+ * lookup cannot miss.
216
+ *
217
+ * @param {string} errorType - A category ErrorClassifier declares
218
+ * @returns {string} Severity for that category
88
219
  * @private
89
220
  */
90
221
  _determineSeverity(errorType) {
91
- const severityMap = {
92
- FATAL: 'fatal',
93
- BUSINESS: 'error',
94
- VALIDATION: 'warn',
95
- TRANSIENT: 'error',
96
- TIMEOUT: 'error',
97
- RATE_LIMIT: 'warn',
98
- UNKNOWN: 'error'
99
- };
100
-
101
- return severityMap[errorType] || 'error';
222
+ return SEVERITY_BY_TYPE[errorType];
102
223
  }
103
224
  }
104
225
 
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Range and shape checks for the configuration this package accepts.
5
+ *
6
+ * d.173 stopped a default from overruling an explicit value, so an explicit
7
+ * value now reaches the component that owns the key. Nothing then checked WHAT
8
+ * that value was: `maxRetries: 0` produced a loop that never runs and a
9
+ * `throw lastError` at its end handing the caller `undefined`, and
10
+ * `rollingCountBuckets: 0` divides the statistics window by zero. The checks
11
+ * belong in the constructor (`.claude/rules/architecture-principles.md` §4
12
+ * fail-fast) and live here, once, because three constructors ask the same two
13
+ * questions — a second copy of one rule is a second rail
14
+ * (`.claude/rules/change-discipline.md` § One rail per concern).
15
+ *
16
+ * A wrong type (`'3'`, `NaN`, `null`) is the same class of refusal as a value
17
+ * out of range: both mean the caller's configuration cannot be honoured, and
18
+ * silently substituting a default for either is the defect d.173 removed.
19
+ */
20
+
21
+ /**
22
+ * Render a rejected value so the message says what actually arrived — a message
23
+ * naming only the rule leaves the reader guessing which of their keys is wrong.
24
+ * Exported because a refusal that is not about a range (`circuitBreakerOptions`
25
+ * that is not an object) must render the value the same way; two renderings of
26
+ * one fact would be two rails (`.claude/rules/change-discipline.md`).
27
+ *
28
+ * @param {*} value - Value as received
29
+ * @returns {string} Human-readable rendering, type included
30
+ */
31
+ function describeValue(value) {
32
+ if (typeof value === 'string') return `string ${JSON.stringify(value)}`;
33
+ if (typeof value === 'number') return Number.isNaN(value) ? 'NaN' : `number ${value}`;
34
+ if (value === null) return 'null';
35
+ if (value === undefined) return 'undefined';
36
+ return `${typeof value} ${String(value)}`;
37
+ }
38
+
39
+ /**
40
+ * Refuse anything that is not a finite number inside the declared range.
41
+ *
42
+ * @param {string} context - Component raising the refusal (message header per §5)
43
+ * @param {string} key - Configuration key, spelled as the caller writes it
44
+ * @param {*} value - Value as received
45
+ * @param {Object} spec - The rule
46
+ * @param {string} spec.expected - The rule in words, used in the message
47
+ * @param {boolean} [spec.integer=false] - Whole numbers only
48
+ * @param {number} [spec.min] - Inclusive lower bound
49
+ * @param {number} [spec.exclusiveMin] - Exclusive lower bound
50
+ * @param {number} [spec.max] - Inclusive upper bound
51
+ * @throws {Error} If the value is not a finite number satisfying the rule
52
+ */
53
+ function assertNumberInRange(context, key, value, spec) {
54
+ const { expected, integer = false, min, exclusiveMin, max } = spec;
55
+
56
+ const accepted =
57
+ typeof value === 'number'
58
+ && Number.isFinite(value)
59
+ && (!integer || Number.isInteger(value))
60
+ && (min === undefined || value >= min)
61
+ && (exclusiveMin === undefined || value > exclusiveMin)
62
+ && (max === undefined || value <= max);
63
+
64
+ if (!accepted) {
65
+ throw new Error(
66
+ `[${context}] Invalid configuration value - "${key}" must be ${expected}, `
67
+ + `got ${describeValue(value)}. Fix: pass ${expected}, or omit "${key}" to take the documented default.`
68
+ );
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Refuse a DLQ naming pattern that cannot name a queue: an empty one would send
74
+ * dead messages to a queue with an empty name, and one without the `{queue}`
75
+ * placeholder would collapse every origin into a single queue.
76
+ *
77
+ * @param {string} context - Component raising the refusal
78
+ * @param {string} key - Configuration key
79
+ * @param {*} value - Value as received
80
+ * @throws {Error} If the value is not a non-empty string containing `{queue}`
81
+ */
82
+ function assertQueuePattern(context, key, value) {
83
+ if (typeof value !== 'string' || !value.includes('{queue}')) {
84
+ throw new Error(
85
+ `[${context}] Invalid configuration value - "${key}" must be a non-empty string containing `
86
+ + `the "{queue}" placeholder, got ${describeValue(value)}. `
87
+ + `Fix: pass a pattern such as "{queue}.dlq", or omit "${key}" to take the documented default.`
88
+ );
89
+ }
90
+ }
91
+
92
+ module.exports = { describeValue, assertNumberInRange, assertQueuePattern };
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ const RetryHandler = require('./RetryHandler');
10
10
  const CircuitBreakerManager = require('./CircuitBreaker');
11
11
  const DLQRouter = require('./DLQRouter');
12
12
  const CompensationHandler = require('./CompensationHandler');
13
+ const CircuitOpenError = require('./CircuitOpenError');
13
14
 
14
15
  module.exports = {
15
16
  // Main class
@@ -22,6 +23,9 @@ module.exports = {
22
23
  CircuitBreakerManager,
23
24
  DLQRouter,
24
25
  CompensationHandler,
26
+
27
+ // Typed errors (branch on error.code, never on error.message)
28
+ CircuitOpenError,
25
29
 
26
30
  // Constants
27
31
  ErrorTypes,