@onlineapps/error-handler-core 2.0.1 → 3.1.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 +63 -0
- package/README.md +24 -6
- package/package.json +1 -1
- package/src/ErrorClassifier.js +7 -0
- package/src/ErrorHandlerCore.js +15 -21
- package/src/UnifiedLogSchema.js +5 -0
- package/src/configRanges.js +1 -21
- package/src/index.js +0 -2
- package/src/DLQRouter.js +0 -251
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,69 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [3.1.0] — 2026-09-25
|
|
8
|
+
|
|
9
|
+
### Added — třída `ErrorTypes.DECLINED`: pravidlo práci zakázalo (d.831)
|
|
10
|
+
|
|
11
|
+
Každá dosavadní třída pojmenovává způsob, jakým se práce NEPOVEDLA. `DECLINED` říká,
|
|
12
|
+
že se dělat neměla: doklad už odeslaný byl, položku zdroj neprojednává. Je koncová,
|
|
13
|
+
není to selhání, neopakuje se a nejde do DLQ — `ErrorHandlerCore` pro ni vrací verdikt
|
|
14
|
+
`declined`, který je (stejně jako `dlq`) klasifikací pro volajícího, ne cestou, kterou
|
|
15
|
+
by tenhle balíček sám šel.
|
|
16
|
+
|
|
17
|
+
Deklaruje ji VYHAZUJÍCÍ přes `error.type`; ze stavového kódu se neodvozuje. Tabulka
|
|
18
|
+
kódů zůstává beze změny — každé jiné `409` je dál `BUSINESS`, protože týž stav nese
|
|
19
|
+
oba významy a jen vyhazující ví, který z nich myslel.
|
|
20
|
+
|
|
21
|
+
Rozhodnutí vlastníka:
|
|
22
|
+
`api/docs/governance/confirmations/workflow-failed-status-projection.md` 004
|
|
23
|
+
(odmítnutí bránou komba není selhání běhu). Závažnost logu `info`, protože to není
|
|
24
|
+
incident; mapa kategorií v `UnifiedLogSchema` ji kontroluje při načtení, takže třída
|
|
25
|
+
bez závažnosti balíček neprojde.
|
|
26
|
+
|
|
27
|
+
## [3.0.0] — 2026-09-16
|
|
28
|
+
|
|
29
|
+
### Removed — **BREAKING** `DLQRouter`: dead-lettering má jednu kolej, a není v tomto balíčku
|
|
30
|
+
|
|
31
|
+
`DLQRouter` publikoval mrtvou zprávu do `<fronta>.dlq` přímo a vedle toho emitoval
|
|
32
|
+
vlastní událost `message_dlq` se `service_name: 'dlq-router'`. Totéž — odklad zprávy
|
|
33
|
+
do `<služba>.dlq` i událost `message_dlq` — dělá politika `consume()`
|
|
34
|
+
v `@onlineapps/mq-client-core` (počítadlo pokusů v hlavičce, `nack(requeue=false)`).
|
|
35
|
+
Dvě koleje pro jednu věc jsou vada, ne záloha (`.claude/rules/change-discipline.md`
|
|
36
|
+
§ One rail per concern); rozhodl to vlastník v
|
|
37
|
+
`docs/governance/confirmations/mq-consumer-contract.md` 002 bod 3 („the only rail
|
|
38
|
+
for biz and infra alike").
|
|
39
|
+
|
|
40
|
+
Čtyři otázky § Removing something removes its declaration:
|
|
41
|
+
(1) **vznik** — konektorová DLQ kolej z doby před brokerovou dead-letter politikou;
|
|
42
|
+
(2) **koncept** — nesla ji tato třída, konfirmace 002 bod 3 ji nahradila;
|
|
43
|
+
(3) **proč ji nikdo nečte** — ustoupila rozhodnutí, ne zapomnění: `routeToDLQ(` měl
|
|
44
|
+
2026-09-15 nula volajících mimo tento balíček a `@onlineapps/conn-infra-error-handler`
|
|
45
|
+
(měřeno přes `shared`, `infra`, `api_biz/*`, `fe_adminui`); wrapper volá jen
|
|
46
|
+
`errorHandler.logError`, orchestrátor jen `classifyError`;
|
|
47
|
+
(4) **náhrada je koncepčnější** — ano: broker odkládá, jeden emitor vydává jednu
|
|
48
|
+
událost.
|
|
49
|
+
|
|
50
|
+
Co odchází s ní, protože jinak by zbyla deklarace bez čtenáře:
|
|
51
|
+
|
|
52
|
+
- export `DLQRouter` z `src/index.js` a pole `dlqRouter` na instanci `ErrorHandlerCore`;
|
|
53
|
+
- větev `handleError()`, která na akci `dlq` routovala. **Klasifikace zůstává:**
|
|
54
|
+
`handleError()` dál vrací `{ action: 'dlq' }` — je to rozhodnutí předané volajícímu,
|
|
55
|
+
ne provedená akce, a nic v tomto balíčku už nepublikuje;
|
|
56
|
+
- klíče `handling.dlqEnabled` a `handling.mqClient` konstruktoru `ErrorHandlerCore`,
|
|
57
|
+
a klíče `dlqNamePattern` a `emitMonitoringEvents`, které četl jen `DLQRouter`.
|
|
58
|
+
Oba je posílal `@onlineapps/service-wrapper` (`dlqEnabled` seznamem povolených
|
|
59
|
+
klíčů `errorHandling`, `mqClient` přiložením k objektu `handling`) — po této
|
|
60
|
+
změně je nic nečte, a wrapper je proto vyřadil na své straně: `dlqEnabled`
|
|
61
|
+
v d.493, `handling.mqClient` v d.502;
|
|
62
|
+
- `assertQueuePattern()` z `src/configRanges.js` — `DLQRouter` byl jediný čtenář.
|
|
63
|
+
`describeValue` a `assertNumberInRange` zůstávají (čtou je `CircuitBreaker`
|
|
64
|
+
a `UnifiedLogSchema`).
|
|
65
|
+
|
|
66
|
+
Migrace: volající, který se spoléhal na to, že ho jádro dostane do `.dlq`, nemá co
|
|
67
|
+
měnit — zprávu odkládá konzumentská politika knihovny `@onlineapps/mq-client-core`.
|
|
68
|
+
Volající, který konstruoval `DLQRouter` přímo, ho nemá čím nahradit v tomto balíčku.
|
|
69
|
+
|
|
7
70
|
## [2.0.1] — 2026-09-15
|
|
8
71
|
|
|
9
72
|
## [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
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
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
|
|
@@ -149,6 +151,22 @@ Branch on `error.code`, never on `error.message`: the wording follows
|
|
|
149
151
|
`.claude/rules/architecture-principles.md` §5 and may be improved, the code and
|
|
150
152
|
`details.name` are the contract.
|
|
151
153
|
|
|
154
|
+
## `DECLINED` — a rule forbade the work
|
|
155
|
+
|
|
156
|
+
Every class in `ErrorTypes` names a way the work FAILED, except this one. `DECLINED`
|
|
157
|
+
says the work was refused: the domain rule that was asked says it must not happen —
|
|
158
|
+
a document already sent, an item the source will not discuss. It is terminal, it is
|
|
159
|
+
not a failure, it is not retried and it is not dead-lettered; `ErrorHandlerCore`
|
|
160
|
+
answers it with the verdict `declined`, which — like `dlq` — is a classification
|
|
161
|
+
returned to the caller, not a route this package takes.
|
|
162
|
+
|
|
163
|
+
It is declared by the THROWER, through `error.type`, and never derived from a status:
|
|
164
|
+
every other `409` stays `BUSINESS`, table and all. That is deliberate, because the
|
|
165
|
+
same status carries both meanings and only the thrower knows which one it meant.
|
|
166
|
+
|
|
167
|
+
@see api/docs/governance/confirmations/workflow-failed-status-projection.md — 004
|
|
168
|
+
@see api/docs/biz/70-contracts/error-handling.md — the error classes
|
|
169
|
+
|
|
152
170
|
## API Reference
|
|
153
171
|
|
|
154
172
|
See [ERROR_HANDLING.md](/docs/standards/ERROR_HANDLING.md) for the cross-cutting standard (classification table, fail-loudly rules).
|
package/package.json
CHANGED
package/src/ErrorClassifier.js
CHANGED
|
@@ -11,6 +11,13 @@ const ErrorTypes = {
|
|
|
11
11
|
VALIDATION: 'VALIDATION', // Input validation - DLQ
|
|
12
12
|
TIMEOUT: 'TIMEOUT', // Operation timeout - RETRY
|
|
13
13
|
RATE_LIMIT: 'RATE_LIMIT', // Rate limiting - RETRY with backoff
|
|
14
|
+
// A rule forbade the work. TERMINAL and NOT a failure: the caller asked for
|
|
15
|
+
// something the domain says must not happen — a document already sent, an
|
|
16
|
+
// item the source will not discuss — so there is nothing to retry and nothing
|
|
17
|
+
// to dead-letter. It is declared by the thrower through `error.type`, never
|
|
18
|
+
// derived from a status: every other 409 stays BUSINESS.
|
|
19
|
+
// @see api/docs/governance/confirmations/workflow-failed-status-projection.md 004
|
|
20
|
+
DECLINED: 'DECLINED', // Refused by a rule - TERMINAL, no retry, no DLQ
|
|
14
21
|
UNKNOWN: 'UNKNOWN' // Unclassified - LOG and THROW
|
|
15
22
|
};
|
|
16
23
|
|
package/src/ErrorHandlerCore.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Error Handler Core
|
|
3
|
-
* Main class that orchestrates error handling, logging, retry, circuit breaker,
|
|
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
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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,
|
|
@@ -330,6 +316,14 @@ class ErrorHandlerCore {
|
|
|
330
316
|
case ErrorTypes.FATAL:
|
|
331
317
|
// Fatal errors, throw immediately
|
|
332
318
|
return { type: 'throw' };
|
|
319
|
+
|
|
320
|
+
case ErrorTypes.DECLINED:
|
|
321
|
+
// A rule forbade the work: terminal, and neither a retry nor a failure.
|
|
322
|
+
// Its own verdict rather than `dlq`, because dead-lettering says "look at
|
|
323
|
+
// this", and there is nothing to look at — the refusal IS the outcome
|
|
324
|
+
// (conf `workflow-failed-status-projection` 004). Like `dlq`, this is a
|
|
325
|
+
// CLASSIFICATION returned to the caller; this class routes nothing.
|
|
326
|
+
return { type: 'declined' };
|
|
333
327
|
|
|
334
328
|
default:
|
|
335
329
|
// Unknown errors, log and throw
|
package/src/UnifiedLogSchema.js
CHANGED
|
@@ -25,6 +25,11 @@ const SEVERITY_BY_TYPE = {
|
|
|
25
25
|
[ErrorTypes.VALIDATION]: 'warn',
|
|
26
26
|
[ErrorTypes.TIMEOUT]: 'error',
|
|
27
27
|
[ErrorTypes.RATE_LIMIT]: 'warn',
|
|
28
|
+
// Not an incident: a rule said no, and the run ended on that. `info` is the
|
|
29
|
+
// severity of a thing that happened as designed — filing it as `error` would
|
|
30
|
+
// put every refused send into the operator's error stream, which is the noise
|
|
31
|
+
// conf `workflow-failed-status-projection` 004 exists to remove.
|
|
32
|
+
[ErrorTypes.DECLINED]: 'info',
|
|
28
33
|
[ErrorTypes.UNKNOWN]: 'error'
|
|
29
34
|
};
|
|
30
35
|
|
package/src/configRanges.js
CHANGED
|
@@ -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
|
-
|