@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/UnifiedLogSchema.js
CHANGED
|
@@ -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
|
-
*
|
|
21
|
-
* @param {string} params.
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
51
|
-
moduleName
|
|
52
|
-
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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,
|