@onlineapps/error-handler-core 2.0.1 → 3.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,49 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.0.0] — 2026-09-16
8
+
9
+ ### Removed — **BREAKING** `DLQRouter`: dead-lettering má jednu kolej, a není v tomto balíčku
10
+
11
+ `DLQRouter` publikoval mrtvou zprávu do `<fronta>.dlq` přímo a vedle toho emitoval
12
+ vlastní událost `message_dlq` se `service_name: 'dlq-router'`. Totéž — odklad zprávy
13
+ do `<služba>.dlq` i událost `message_dlq` — dělá politika `consume()`
14
+ v `@onlineapps/mq-client-core` (počítadlo pokusů v hlavičce, `nack(requeue=false)`).
15
+ Dvě koleje pro jednu věc jsou vada, ne záloha (`.claude/rules/change-discipline.md`
16
+ § One rail per concern); rozhodl to vlastník v
17
+ `docs/governance/confirmations/mq-consumer-contract.md` 002 bod 3 („the only rail
18
+ for biz and infra alike").
19
+
20
+ Čtyři otázky § Removing something removes its declaration:
21
+ (1) **vznik** — konektorová DLQ kolej z doby před brokerovou dead-letter politikou;
22
+ (2) **koncept** — nesla ji tato třída, konfirmace 002 bod 3 ji nahradila;
23
+ (3) **proč ji nikdo nečte** — ustoupila rozhodnutí, ne zapomnění: `routeToDLQ(` měl
24
+ 2026-09-15 nula volajících mimo tento balíček a `@onlineapps/conn-infra-error-handler`
25
+ (měřeno přes `shared`, `infra`, `api_biz/*`, `fe_adminui`); wrapper volá jen
26
+ `errorHandler.logError`, orchestrátor jen `classifyError`;
27
+ (4) **náhrada je koncepčnější** — ano: broker odkládá, jeden emitor vydává jednu
28
+ událost.
29
+
30
+ Co odchází s ní, protože jinak by zbyla deklarace bez čtenáře:
31
+
32
+ - export `DLQRouter` z `src/index.js` a pole `dlqRouter` na instanci `ErrorHandlerCore`;
33
+ - větev `handleError()`, která na akci `dlq` routovala. **Klasifikace zůstává:**
34
+ `handleError()` dál vrací `{ action: 'dlq' }` — je to rozhodnutí předané volajícímu,
35
+ ne provedená akce, a nic v tomto balíčku už nepublikuje;
36
+ - klíče `handling.dlqEnabled` a `handling.mqClient` konstruktoru `ErrorHandlerCore`,
37
+ a klíče `dlqNamePattern` a `emitMonitoringEvents`, které četl jen `DLQRouter`.
38
+ Oba je posílal `@onlineapps/service-wrapper` (`dlqEnabled` seznamem povolených
39
+ klíčů `errorHandling`, `mqClient` přiložením k objektu `handling`) — po této
40
+ změně je nic nečte, a wrapper je proto vyřadil na své straně: `dlqEnabled`
41
+ v d.493, `handling.mqClient` v d.502;
42
+ - `assertQueuePattern()` z `src/configRanges.js` — `DLQRouter` byl jediný čtenář.
43
+ `describeValue` a `assertNumberInRange` zůstávají (čtou je `CircuitBreaker`
44
+ a `UnifiedLogSchema`).
45
+
46
+ Migrace: volající, který se spoléhal na to, že ho jádro dostane do `.dlq`, nemá co
47
+ měnit — zprávu odkládá konzumentská politika knihovny `@onlineapps/mq-client-core`.
48
+ Volající, který konstruoval `DLQRouter` přímo, ho nemá čím nahradit v tomto balíčku.
49
+
7
50
  ## [2.0.1] — 2026-09-15
8
51
 
9
52
  ## [2.0.0] — 2026-09-14
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  > Status: current
2
- > Owns: the platform's error handling itself — classification, retry, circuit breaker, DLQ routing and compensation
2
+ > Owns: the platform's error handling itself — classification, retry, circuit breaker and compensation
3
3
 
4
4
  <!-- BEGIN GENERATED: library-uniform — regenerate: npx oa-sync-template readme-uniform --all -->
5
5
  Uniform: [library/connector](../connector/conn-orch-validator/manifests/library.manifest.json)
@@ -19,7 +19,6 @@ Core error handling functionality for OA Drive microservices. Used by both busin
19
19
  - **Error Classification** - Automatic error type detection (TRANSIENT, BUSINESS, FATAL, etc.)
20
20
  - **Retry Logic** - Exponential backoff for transient errors
21
21
  - **Circuit Breaker** - Protection against cascading failures
22
- - **DLQ Routing** - Dead letter queue routing for permanent errors
23
22
  - **Compensation** - Rollback operations for failed workflows
24
23
  - **Unified Logging** - Structured error logging via monitoring-core
25
24
 
@@ -54,8 +53,7 @@ const errorHandler = new ErrorHandlerCore({
54
53
  retryDelay: 1000,
55
54
  retryMultiplier: 2,
56
55
  circuitBreakerEnabled: true,
57
- dlqEnabled: true,
58
- mqClient: mqClient // Optional, for DLQ routing
56
+ compensationEnabled: true
59
57
  }
60
58
  });
61
59
  ```
@@ -65,8 +63,8 @@ const errorHandler = new ErrorHandlerCore({
65
63
  `executeWithRetry()` loops three times, and a caller driving the loop itself from
66
64
  `handleError()`'s `result.shouldRetry` stops after the third as well. The other
67
65
  `handling` keys, their defaults and their accepted ranges belong to the component
68
- that reads each key (`RetryHandler`, `CircuitBreakerManager`, `DLQRouter`); a
69
- value outside its range is refused in the constructor.
66
+ that reads each key (`RetryHandler`, `CircuitBreakerManager`); a value outside its
67
+ range is refused in the constructor.
70
68
 
71
69
  ### Log Error
72
70
 
@@ -99,6 +97,10 @@ try {
99
97
  });
100
98
 
101
99
  // result.action: 'retry' | 'dlq' | 'throw' | 'continue' | 'compensate'
100
+ // `dlq` is a CLASSIFICATION handed back to the caller: this package publishes
101
+ // nothing. A message is dead-lettered by the consumer policy of
102
+ // `@onlineapps/mq-client-core` — one rail for biz and infra alike
103
+ // (docs/governance/confirmations/mq-consumer-contract.md 002 bod 3).
102
104
  if (result.action === 'retry') {
103
105
  await sleep(result.retryDelay);
104
106
  // Retry operation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/error-handler-core",
3
- "version": "2.0.1",
3
+ "version": "3.0.0",
4
4
  "description": "Core error handling functionality for OA Drive microservices - classification, retry, circuit breaker, DLQ routing",
5
5
  "oa": {
6
6
  "category": "connector"
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * Error Handler Core
3
- * Main class that orchestrates error handling, logging, retry, circuit breaker, DLQ routing
3
+ * Main class that orchestrates error handling, logging, retry, circuit breaker, compensation
4
4
  */
5
5
 
6
6
  const { ErrorClassifier, ErrorTypes } = require('./ErrorClassifier');
7
7
  const UnifiedLogSchema = require('./UnifiedLogSchema');
8
8
  const RetryHandler = require('./RetryHandler');
9
9
  const CircuitBreakerManager = require('./CircuitBreaker');
10
- const DLQRouter = require('./DLQRouter');
11
10
  const CompensationHandler = require('./CompensationHandler');
12
11
  const runtimeCfg = require('./config');
13
12
  const { assertNumberInRange } = require('./configRanges');
@@ -24,7 +23,7 @@ class ErrorHandlerCore {
24
23
  * @param {Object} config.monitoring - Monitoring instance (monitoring-core)
25
24
  * @param {Object} config.logger - Logger with info/warn/error/debug (required).
26
25
  * 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.
26
+ * here — so it cannot vanish with it.
28
27
  * @param {Object} [config.handling] - Error handling configuration. Every key
29
28
  * below is optional and this class declares NO default for any of them: the
30
29
  * value, its documented default and its accepted range belong to the
@@ -38,8 +37,6 @@ class ErrorHandlerCore {
38
37
  * @param {number} [config.handling.errorThreshold] - see `CircuitBreakerManager`
39
38
  * @param {number} [config.handling.resetTimeout] - see `CircuitBreakerManager`
40
39
  * @param {boolean} [config.handling.circuitBreakerEnabled=true] - Enable circuit breaker
41
- * @param {boolean} [config.handling.dlqEnabled=true] - Enable DLQ routing
42
- * @param {Object} [config.handling.mqClient] - MQ client for DLQ
43
40
  * @param {boolean} [config.handling.compensationEnabled=true] - Enable compensation
44
41
  * @throws {Error} If a handling value is outside the range its component declares
45
42
  */
@@ -99,7 +96,7 @@ class ErrorHandlerCore {
99
96
  this.logSchema = new UnifiedLogSchema();
100
97
  // Values are forwarded as given — `undefined` included. The default for
101
98
  // each key is declared once, by the component that owns it (RetryHandler,
102
- // CircuitBreakerManager, DLQRouter); re-stating the same numbers here made
99
+ // CircuitBreakerManager); re-stating the same numbers here made
103
100
  // two rails for one fact and, being `||`, turned an explicit 0 into the
104
101
  // default before the owning class ever saw it.
105
102
  this.retryHandler = new RetryHandler({
@@ -114,11 +111,6 @@ class ErrorHandlerCore {
114
111
  errorThreshold: handling.errorThreshold,
115
112
  resetTimeout: handling.resetTimeout
116
113
  });
117
- this.dlqRouter = new DLQRouter({
118
- dlqEnabled: handling.dlqEnabled !== false,
119
- mqClient: handling.mqClient,
120
- logger: this.logger
121
- });
122
114
  this.compensation = new CompensationHandler({
123
115
  compensationEnabled: handling.compensationEnabled !== false
124
116
  });
@@ -228,16 +220,10 @@ class ErrorHandlerCore {
228
220
  maxRetries: this._resolveMaxRetries(context)
229
221
  };
230
222
 
231
- // 5. Execute action
232
- if (action.type === 'dlq' && this.dlqRouter.isAvailable()) {
233
- await this.dlqRouter.routeToDLQ(
234
- context.message || {},
235
- error,
236
- errorType,
237
- context
238
- );
239
- }
240
-
223
+ // 5. Execute action. `dlq` is a CLASSIFICATION returned to the caller, not
224
+ // a route taken here: a message is dead-lettered by the consumer policy of
225
+ // `@onlineapps/mq-client-core` — one rail for biz and infra alike
226
+ // (`docs/governance/confirmations/mq-consumer-contract.md` 002 bod 3).
241
227
  if (action.type === 'compensate' && this.compensation.enabled) {
242
228
  const compensationResult = await this.compensation.execute(
243
229
  operation,
@@ -69,24 +69,4 @@ function assertNumberInRange(context, key, value, spec) {
69
69
  }
70
70
  }
71
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 };
72
+ module.exports = { describeValue, assertNumberInRange };
package/src/index.js CHANGED
@@ -8,7 +8,6 @@ const { ErrorClassifier, ErrorTypes, ErrorCodes } = require('./ErrorClassifier')
8
8
  const UnifiedLogSchema = require('./UnifiedLogSchema');
9
9
  const RetryHandler = require('./RetryHandler');
10
10
  const CircuitBreakerManager = require('./CircuitBreaker');
11
- const DLQRouter = require('./DLQRouter');
12
11
  const CompensationHandler = require('./CompensationHandler');
13
12
  const CircuitOpenError = require('./CircuitOpenError');
14
13
 
@@ -21,7 +20,6 @@ module.exports = {
21
20
  UnifiedLogSchema,
22
21
  RetryHandler,
23
22
  CircuitBreakerManager,
24
- DLQRouter,
25
23
  CompensationHandler,
26
24
 
27
25
  // Typed errors (branch on error.code, never on error.message)
package/src/DLQRouter.js DELETED
@@ -1,251 +0,0 @@
1
- /**
2
- * Dead Letter Queue Router
3
- * Routes failed messages to DLQ for manual inspection
4
- */
5
-
6
- const { assertLogger } = require('@onlineapps/logger-contract');
7
- const { assertNumberInRange, assertQueuePattern } = require('./configRanges');
8
-
9
- /**
10
- * Retries already made before the message was dead-lettered. Read in two places
11
- * (the DLQ message and the monitoring event), so the rule that reads it lives in
12
- * one (`.claude/rules/change-discipline.md` § One rail per concern).
13
- *
14
- * `??`, not `||`: `retryCount: 0` on the message is a measurement, and `||`
15
- * skipped it in favour of the context — which is not merely untidy, because
16
- * `_determineRoutingReason()` compares that number with `maxRetries` and would
17
- * file the message under a reason nobody produced.
18
- *
19
- * @param {Object} message - Original message
20
- * @param {Object} context - Additional context
21
- * @returns {number} Accepted retry count
22
- * @throws {Error} If the value present is not an integer >= 0
23
- */
24
- function resolveRetryCount(message, context) {
25
- const retryCount = message.retryCount ?? context.retryCount ?? 0;
26
- assertNumberInRange('DLQRouter', 'retryCount', retryCount, {
27
- integer: true,
28
- min: 0,
29
- expected: 'an integer >= 0 (the number of retries already made)'
30
- });
31
- return retryCount;
32
- }
33
-
34
- class DLQRouter {
35
- /**
36
- * @param {Object} config - Configuration
37
- * @param {Object} config.logger - Logger with info/warn/error/debug (required)
38
- * @param {boolean} [config.dlqEnabled=true] - Enable DLQ routing
39
- * @param {Object} [config.mqClient] - MQ client used to publish to the DLQ
40
- * @param {string} [config.dlqNamePattern='{queue}.dlq'] - DLQ naming pattern;
41
- * a non-empty string containing the `{queue}` placeholder
42
- * @param {boolean} [config.emitMonitoringEvents=true] - Emit message_dlq events
43
- * @throws {Error} If dlqNamePattern cannot name a per-origin queue
44
- */
45
- constructor(config = {}) {
46
- // `dlqNamePattern` and `mqClient` carry their documented defaults as
47
- // destructuring defaults, declared here once: a default stands in for a
48
- // MISSING key, it never overrules what the caller passed
49
- // (`.claude/rules/architecture-principles.md` §3). `logger` is read from
50
- // `config` below because assertLogger owns its (absent) default: it throws.
51
- const { mqClient = null, dlqNamePattern = '{queue}.dlq' } = config;
52
- assertQueuePattern('DLQRouter', 'dlqNamePattern', dlqNamePattern);
53
-
54
- this.enabled = config.dlqEnabled !== false;
55
- this.mqClient = mqClient;
56
- this.dlqNamePattern = dlqNamePattern;
57
- this.emitMonitoringEvents = config.emitMonitoringEvents !== false;
58
- // Both failure paths below deliberately swallow their error so that DLQ
59
- // routing never breaks error handling; the logger is what keeps them visible.
60
- this.logger = assertLogger(
61
- 'DLQRouter',
62
- config.logger,
63
- 'a DLQ routing failure this router deliberately swallows still reaches the structured log'
64
- );
65
- }
66
-
67
- /**
68
- * Route message to DLQ
69
- *
70
- * @param {Object} message - Original message
71
- * @param {string} [message.queue] - Queue the message came from; required
72
- * unless `context.queue` carries it
73
- * @param {Error} error - Error that occurred
74
- * @param {string} errorType - Classified error type
75
- * @param {Object} context - Additional context
76
- * @param {string} [context.queue] - Queue of origin, when the message body
77
- * does not carry it
78
- * @returns {Promise<void>}
79
- * @throws {Error} If neither the message nor the context names the queue
80
- */
81
- async routeToDLQ(message, error, errorType, context = {}) {
82
- if (!this.enabled || !this.mqClient) {
83
- return; // DLQ disabled or no MQ client
84
- }
85
-
86
- // The queue of origin is the only thing a dead message can be found by, so a
87
- // missing one is refused here — before anything is published — rather than
88
- // renamed. `… || 'unknown'` used to file every such message in `unknown.dlq`,
89
- // a queue nobody is looking in, and said nothing about it. Owner's rule:
90
- // no invented value (`api/docs/governance/confirmations/dlq-purge.md` 002).
91
- const originalQueue = message.queue ?? context.queue;
92
- if (typeof originalQueue !== 'string' || originalQueue.trim() === '') {
93
- throw new Error(
94
- '[DLQRouter] Missing required field - "queue" must be a non-empty string naming the queue the '
95
- + 'message came from; a dead message is found by nothing else. '
96
- + 'Fix: pass message.queue, or context.queue when the body does not carry it.'
97
- );
98
- }
99
- const retryCount = resolveRetryCount(message, context);
100
- const dlqName = this._generateDLQName(originalQueue);
101
-
102
- const dlqMessage = {
103
- // Original message
104
- ...message,
105
-
106
- // Error details
107
- error: {
108
- message: error.message,
109
- stack: error.stack,
110
- // `??`: a code that is present but falsy is a value; `null` is reserved
111
- // for the absence of one.
112
- code: error.code ?? null,
113
- type: errorType,
114
- timestamp: new Date().toISOString()
115
- },
116
-
117
- // Routing metadata
118
- originalQueue: originalQueue,
119
- retryCount,
120
- routedAt: new Date().toISOString(),
121
- routingReason: this._determineRoutingReason(errorType, context, retryCount)
122
- };
123
-
124
- try {
125
- await this.mqClient.publish(dlqName, dlqMessage, {
126
- persistent: true
127
- });
128
-
129
- // Emit message_dlq monitoring event
130
- if (this.emitMonitoringEvents) {
131
- await this._emitDLQEvent(message, dlqName, originalQueue, error, errorType, context, retryCount);
132
- }
133
- } catch (dlqError) {
134
- // If DLQ routing fails, log but don't throw
135
- // (we don't want DLQ failures to break error handling)
136
- this.logger.error('[DLQRouter] DLQ routing failed', {
137
- dlqName,
138
- originalQueue,
139
- errorType,
140
- reason: dlqError.message
141
- });
142
- }
143
- }
144
-
145
- /**
146
- * Emit message_dlq event to monitoring
147
- *
148
- * @param {number} retryCount - Already resolved and checked by the caller, so
149
- * both records of one dead message state the same number
150
- * @private
151
- */
152
- async _emitDLQEvent(message, dlqName, originalQueue, error, errorType, context, retryCount) {
153
- if (!this.mqClient) return;
154
-
155
- // Resolved before the try so the catch below can name the workflow it failed on.
156
- //
157
- // `null`, not `'unknown'`: a dead-lettered message need not belong to a
158
- // workflow, but an absent id is never renamed into one. The consumer of
159
- // `monitoring.workflow` takes any non-empty string as a real id
160
- // (`infra/api_monitoring/src/consumer/apiflow-extension.js`, readReported +
161
- // the MISSING_WORKFLOW_ID gate), so `'unknown'` wrote `workflow_traces` rows
162
- // claiming a workflow by that name; `null` is the absence the message
163
- // actually states, and that gate counts it as faulty telemetry instead.
164
- // Owner: `api/docs/governance/confirmations/dlq-purge.md` 002.
165
- const workflowId = message.workflow_id ?? context.workflow_id ?? null;
166
-
167
- try {
168
- await this.mqClient.publish('monitoring.workflow', {
169
- event_type: 'message_dlq',
170
- workflow_id: workflowId,
171
- service_name: 'dlq-router',
172
- step_id: 'dlq-entry',
173
- timestamp: new Date().toISOString(),
174
- context: {
175
- _dlq: {
176
- queue: dlqName,
177
- originalQueue: originalQueue,
178
- reason: error.message,
179
- errorType: errorType,
180
- retryCount,
181
- routedAt: new Date().toISOString()
182
- }
183
- },
184
- metadata: {
185
- dlq_queue: dlqName,
186
- original_queue: originalQueue,
187
- error_type: errorType
188
- }
189
- }, { persistent: true });
190
-
191
- // One per dead-lettered message: a trace of the side effect, not an event
192
- // of its own — the published message_dlq event is the record.
193
- this.logger.debug('[DLQRouter] message_dlq event emitted', { workflowId, dlqName });
194
- } catch (emitError) {
195
- // Silent fail - don't break DLQ routing
196
- this.logger.warn('[DLQRouter] monitoring event emit failed', {
197
- workflowId,
198
- dlqName,
199
- reason: emitError.message
200
- });
201
- }
202
- }
203
-
204
- /**
205
- * Generate DLQ name from original queue name. The constructor has already
206
- * refused a pattern without `{queue}`, so there is no second branch here: the
207
- * one this method used to carry returned a pattern-shaped name for every
208
- * origin, which is the collapse the check now prevents.
209
- * @private
210
- */
211
- _generateDLQName(originalQueue) {
212
- return this.dlqNamePattern.replace('{queue}', originalQueue);
213
- }
214
-
215
- /**
216
- * Determine routing reason.
217
- *
218
- * Takes the SAME resolved `retryCount` the message records. It used to read
219
- * `context.retryCount` on its own, so when the number came on the message the
220
- * two disagreed: the entry said three retries and the reason said the budget
221
- * had nothing to do with it.
222
- *
223
- * @param {string} errorType - Classified error type
224
- * @param {Object} context - Additional context
225
- * @param {number} retryCount - Resolved by `resolveRetryCount()`
226
- * @returns {string} Routing reason
227
- * @private
228
- */
229
- _determineRoutingReason(errorType, context, retryCount) {
230
- if (retryCount >= context.maxRetries) {
231
- return 'max_retries_exceeded';
232
- }
233
-
234
- if (['BUSINESS', 'VALIDATION'].includes(errorType)) {
235
- return 'permanent_error';
236
- }
237
-
238
- return 'error_handling_decision';
239
- }
240
-
241
- /**
242
- * Check if DLQ routing is available
243
- * @returns {boolean} True if DLQ is enabled and MQ client available
244
- */
245
- isAvailable() {
246
- return this.enabled && this.mqClient !== null;
247
- }
248
- }
249
-
250
- module.exports = DLQRouter;
251
-