@onlineapps/error-handler-core 3.0.0 → 4.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,101 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [4.0.0] — 2026-09-27
8
+
9
+ ### Changed — BREAKING — `ErrorClassifier.classify()` už nečte `error.message`: třídu dává jen `type`, `code` a `status` (d.761)
10
+
11
+ Vydaná 3.1.0 měla ve `classify()` čtvrtý krok, který po `type`, `code` a `status`
12
+ hledal v `error.message` (převedeném na malá písmena) podřetězce: `timeout` →
13
+ `TIMEOUT`; `rate limit` / `rate_limit` → `RATE_LIMIT`; `validation` / `invalid` →
14
+ `VALIDATION`; `fatal` / `critical` → `FATAL`; `connection` / `network` / `econn` →
15
+ `TRANSIENT`. Ten krok je pryč. Chyba, kterou nedeklaruje žádné z těch tří polí, je
16
+ `UNKNOWN` bez ohledu na znění — a `UNKNOWN` se neopakuje a loguje se se závažností
17
+ `error`.
18
+
19
+ Důvod: hláška je lidský řetězec, podle kterého kontrakt chyby neklasifikuje, a čtení
20
+ znění byla druhá kolej vedle tabulky kódů (`change-discipline.md` § One rail per
21
+ concern) — každé přeformulování hlášky tiše měnilo třídu. Měření živých producentů
22
+ nenašlo nikoho, kdo by na krok dosáhl (`DeliveryDispatcher._canRetry` skládá
23
+ `{ status, code }` bez hlášky, orchestrátor posílá `type`/`status`/`code`,
24
+ `conn-infra-error-handler` jen deleguje). Jediný zásah byla vnitřní výjimka služby:
25
+ `TypeError` se slovem „invalid" se zařadil jako `VALIDATION`, a tím dostal závažnost
26
+ `warn` — vada našeho kódu podaná jako varování o vstupu. Teď je `UNKNOWN`, tedy
27
+ `error`.
28
+
29
+ Migrace: volající, který chce konkrétní třídu, ji deklaruje strojově — `error.type`
30
+ (hodnota z `ErrorTypes`), `error.code` z tabulky `ErrorCodes`, nebo `error.status`.
31
+ Kontrolní případy drží: `CIRCUIT_OPEN` → `TRANSIENT`, `422` → `VALIDATION`,
32
+ `503` → `TRANSIENT`.
33
+
34
+ ### Fixed — čtyři změřené tvary chyb přenosu mají kategorii (d.774)
35
+
36
+ V tabulce `ErrorCodes` chyběly, takže odpovídaly `UNKNOWN` — a `UNKNOWN` se
37
+ neopakuje, doručení tedy skončilo bez jediného dalšího pokusu:
38
+
39
+ - `EAI_AGAIN` (resolver říká „zeptej se znovu", `getaddrinfo`), `ENETUNREACH`,
40
+ `EHOSTUNREACH` (směrování) → `TRANSIENT` — tatáž rodina jako `ECONNREFUSED`:
41
+ protistrana tam příště být může;
42
+ - `ECONNABORTED` (vypršená lhůta axiosu, který požadavek přeruší sám, proto bez
43
+ `cause` a bez syscallu) → `TIMEOUT`.
44
+
45
+ Tvary jsou změřené na skutečných socketech
46
+ (`infra/api_delivery_dispatcher/tests/unit/transportErrorShapes.test.js`). Nic jiného
47
+ se v tabulce nehnulo: `ECONNREFUSED`, `ENOTFOUND`, `ETIMEDOUT`, `ECONNRESET`, `EPIPE`
48
+ a `CIRCUIT_OPEN` drží svou třídu; trvalé tvary (`EPROTO`, `ERR_INVALID_URL`,
49
+ `ERR_BAD_REQUEST`, certifikátové) a `ERR_CANCELED` zůstávají `UNKNOWN`.
50
+
51
+ ### Documentation — neznámý business kód se neoznačuje ani neodmítá (d.804)
52
+
53
+ README (§ A business code this table does not know) a JSDoc `classify()` říkají
54
+ pravidlo, které kód už měl: jméno business kódu, které tabulka nezná (např.
55
+ `REFERENCE_RECORD_MISSING`), mine vyhledání podle `code` a třídu dá další deklarované
56
+ pole — `error.type`, `error.code` v tabulce, `error.status`. Neznámé jméno na `422` je
57
+ tedy `VALIDATION` jako holé `422`, na `503` `TRANSIENT`; `error.retryable` klasifikátor
58
+ nečte. Slovník jmen business kódů tomuto balíčku nepatří — vlastní ho
59
+ `api/docs/biz/70-contracts/error-handling.md` §1.
60
+
61
+ ### Documentation — `handleError()` bere záznam, ne holou chybu (d.804b)
62
+
63
+ README říká, že `handleError()` vyžaduje tatáž tři pole jako `logError()` —
64
+ `moduleName`, `operation` a `error` (`context` je jediný nepovinný klíč) — a že chybějící
65
+ pole schéma odmítne jménem, v pořadí `moduleName` → `operation` → `error`. Volající,
66
+ který předá zachycenou chybu samotnou, dostane doslova:
67
+ `[UnifiedLogSchema] Missing required field - "moduleName" must be a non-empty string naming the module that failed; it is the caller's to provide and is never invented. Fix: pass params.moduleName (ErrorHandlerCore takes it from errorData.moduleName).`
68
+
69
+ ### Documentation — tabulka kódů je doména HTTP a Node, verdikt AMQP má jiného vlastníka (d.718)
70
+
71
+ README (§ Which codes the classifier answers for): `ErrorClassifier` čte jen vlastní
72
+ `ErrorTypes` (`error.type`), síťové kódy Node (`ECONNREFUSED`, `ETIMEDOUT`, …) a stavové
73
+ kódy HTTP (`error.code` / `error.status`) — `502`, `503` a `504` tu mají význam HTTP.
74
+ Selhání publikace klasifikuje transport, než chyba opustí
75
+ `@onlineapps/mq-client-core` (`src/utils/publishErrors.js`).
76
+
77
+ ### Documentation — README nepíše verzi balíčku rukou (d.730)
78
+
79
+ Patička README tvrdila `*Version: 1.0.0 | License: MIT*`; verzi vlastní
80
+ `package.json`, patička je teď `*License: MIT*`.
81
+
82
+ ## [3.1.0] — 2026-09-25
83
+
84
+ ### Added — třída `ErrorTypes.DECLINED`: pravidlo práci zakázalo (d.831)
85
+
86
+ Každá dosavadní třída pojmenovává způsob, jakým se práce NEPOVEDLA. `DECLINED` říká,
87
+ že se dělat neměla: doklad už odeslaný byl, položku zdroj neprojednává. Je koncová,
88
+ není to selhání, neopakuje se a nejde do DLQ — `ErrorHandlerCore` pro ni vrací verdikt
89
+ `declined`, který je (stejně jako `dlq`) klasifikací pro volajícího, ne cestou, kterou
90
+ by tenhle balíček sám šel.
91
+
92
+ Deklaruje ji VYHAZUJÍCÍ přes `error.type`; ze stavového kódu se neodvozuje. Tabulka
93
+ kódů zůstává beze změny — každé jiné `409` je dál `BUSINESS`, protože týž stav nese
94
+ oba významy a jen vyhazující ví, který z nich myslel.
95
+
96
+ Rozhodnutí vlastníka:
97
+ `api/docs/governance/confirmations/workflow-failed-status-projection.md` 004
98
+ (odmítnutí bránou komba není selhání běhu). Závažnost logu `info`, protože to není
99
+ incident; mapa kategorií v `UnifiedLogSchema` ji kontroluje při načtení, takže třída
100
+ bez závažnosti balíček neprojde.
101
+
7
102
  ## [3.0.0] — 2026-09-16
8
103
 
9
104
  ### Removed — **BREAKING** `DLQRouter`: dead-lettering má jednu kolej, a není v tomto balíčku
package/README.md CHANGED
@@ -108,6 +108,27 @@ try {
108
108
  }
109
109
  ```
110
110
 
111
+ **It takes the RECORD above, not a bare error.** `handleError(error)` is not a
112
+ shorter spelling of `handleError({ error })`: the same three fields `logError()`
113
+ requires are required here too — `moduleName`, `operation` and `error` — because
114
+ `handleError()` logs before it classifies, through the same `UnifiedLogSchema`.
115
+ `context` is the only optional key.
116
+
117
+ A missing one is REFUSED, not defaulted, and the refusal comes from the schema by
118
+ the name of the field, checked in that order (`moduleName` → `operation` →
119
+ `error`):
120
+
121
+ ```
122
+ [UnifiedLogSchema] Missing required field - "moduleName" must be a non-empty string
123
+ naming the module that failed; it is the caller's to provide and is never invented.
124
+ Fix: pass params.moduleName (ErrorHandlerCore takes it from errorData.moduleName).
125
+ ```
126
+
127
+ So a caller that hands `handleError()` the caught error itself gets that message
128
+ about `moduleName` — which names the real defect, but only if the reader knows the
129
+ call takes a record. Measured by
130
+ `tests/unit/business-code-outside-the-dictionary.test.js` § the call contract.
131
+
111
132
  ### Execute with Retry
112
133
 
113
134
  ```javascript
@@ -151,6 +172,64 @@ Branch on `error.code`, never on `error.message`: the wording follows
151
172
  `.claude/rules/architecture-principles.md` §5 and may be improved, the code and
152
173
  `details.name` are the contract.
153
174
 
175
+ ## `DECLINED` — a rule forbade the work
176
+
177
+ Every class in `ErrorTypes` names a way the work FAILED, except this one. `DECLINED`
178
+ says the work was refused: the domain rule that was asked says it must not happen —
179
+ a document already sent, an item the source will not discuss. It is terminal, it is
180
+ not a failure, it is not retried and it is not dead-lettered; `ErrorHandlerCore`
181
+ answers it with the verdict `declined`, which — like `dlq` — is a classification
182
+ returned to the caller, not a route this package takes.
183
+
184
+ It is declared by the THROWER, through `error.type`, and never derived from a status:
185
+ every other `409` stays `BUSINESS`, table and all. That is deliberate, because the
186
+ same status carries both meanings and only the thrower knows which one it meant.
187
+
188
+ @see api/docs/governance/confirmations/workflow-failed-status-projection.md — 004
189
+ @see api/docs/biz/70-contracts/error-handling.md — the error classes
190
+
191
+ ## Which codes the classifier answers for
192
+
193
+ `ErrorClassifier` reads three things and nothing else: this package's own
194
+ `ErrorTypes` (`error.type`), Node's network codes (`ECONNREFUSED`, `ETIMEDOUT`, …)
195
+ and **HTTP status codes** (`error.code` / `error.status`) — so `502`, `503` and
196
+ `504` carry their HTTP meaning here. AMQP reply codes share those numbers with a
197
+ different meaning, and they have a different owner: a publish failure is
198
+ classified inside the transport, before the error leaves it.
199
+
200
+ @see api/shared/mq-client-core/src/utils/publishErrors.js
201
+
202
+ Measured by `tests/unit/classified-domains-one-owner.test.js`: the table's numeric
203
+ keys are HTTP status codes, and the AMQP reply codes HTTP does not use (`406`,
204
+ `530`, `405`, …) are not keys of it.
205
+
206
+ ### A business code this table does not know
207
+
208
+ **The classifier neither labels nor refuses it.** A business name it has never heard
209
+ of — `REFERENCE_RECORD_MISSING`, say — simply misses the `code` lookup, and the class
210
+ comes from the next thing the error declares: `error.type`, then `error.code` in the
211
+ table, then `error.status`. So an unknown name at `422` is `VALIDATION`, exactly like
212
+ a bare `422`; the same unknown name at `503` is `TRANSIENT`. Two different unknown
213
+ names with one status get one class, because **the name is not read at all**.
214
+
215
+ `retryable` is not read here either. It belongs to the error contract and is consumed
216
+ elsewhere (`ErrorMapper.CONTRACT_RETRYABLE_CODES`, the dispatcher's `_canRetry`); on
217
+ this classifier it changes nothing, and whether a retry happens follows from the
218
+ CLASS (`ErrorHandlerCore._decideAction`: `TRANSIENT`/`TIMEOUT`/`RATE_LIMIT` retry,
219
+ `BUSINESS`/`VALIDATION` do not).
220
+
221
+ This is a rule, not an omission: **the dictionary of business code NAMES is not this
222
+ package's to own.** It lives in `api/docs/biz/70-contracts/error-handling.md` §1
223
+ (BIZ-DOCS), and a new name is agreed through the process `error-code-dictionary` 002.
224
+ A name branch here would make the class depend on a dictionary this package cannot
225
+ see, which is two owners of one fact (`change-discipline.md` § One rail per concern).
226
+ Measured 2026-09-22 (d.803): nothing in the workspace marks a business code as
227
+ "unknown" — the only machine-readable code list is `CONTRACT_RETRYABLE_CODES`, which
228
+ is the retryable set and nothing else.
229
+
230
+ Measured by `tests/unit/business-code-outside-the-dictionary.test.js`, including the
231
+ control that the table really does not carry the name.
232
+
154
233
  ## API Reference
155
234
 
156
235
  See [ERROR_HANDLING.md](/docs/standards/ERROR_HANDLING.md) for the cross-cutting standard (classification table, fail-loudly rules).
@@ -167,5 +246,5 @@ See [ERROR_HANDLING.md](/docs/standards/ERROR_HANDLING.md) for the cross-cutting
167
246
 
168
247
  ---
169
248
 
170
- *Version: 1.0.0 | License: MIT*
249
+ *License: MIT*
171
250
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/error-handler-core",
3
- "version": "3.0.0",
3
+ "version": "4.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"
@@ -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
 
@@ -22,6 +29,24 @@ const ErrorCodes = {
22
29
  ETIMEDOUT: ErrorTypes.TRANSIENT,
23
30
  ECONNRESET: ErrorTypes.TRANSIENT,
24
31
  EPIPE: ErrorTypes.TRANSIENT,
32
+
33
+ // The same family, measured on real sockets and missing from this table until d.774:
34
+ // `EAI_AGAIN` is the resolver saying "ask again" (node `dns`, `getaddrinfo`), and
35
+ // `ENETUNREACH` / `EHOSTUNREACH` are routing (node `net`). All three describe a peer
36
+ // that may be reachable on the next attempt, which is what TRANSIENT means here.
37
+ // Until d.774 they were not in the table at all, so they answered UNKNOWN and the
38
+ // delivery was NOT retried.
39
+ // Measured shapes: infra/api_delivery_dispatcher/tests/unit/transportErrorShapes.test.js
40
+ EAI_AGAIN: ErrorTypes.TRANSIENT,
41
+ ENETUNREACH: ErrorTypes.TRANSIENT,
42
+ EHOSTUNREACH: ErrorTypes.TRANSIENT,
43
+
44
+ // axios's own deadline (it aborts the request itself, so there is no `cause` and no
45
+ // syscall underneath) — and the most common real shape of a failed delivery. TIMEOUT
46
+ // is the category this table already has for "the deadline ran out"; UNKNOWN, which is
47
+ // what it answered until d.774, meant the message went to the DLQ without one retry.
48
+ ECONNABORTED: ErrorTypes.TIMEOUT,
49
+
25
50
 
26
51
  // HTTP status codes
27
52
  408: ErrorTypes.TIMEOUT,
@@ -33,8 +58,6 @@ const ErrorCodes = {
33
58
 
34
59
  // Circuit breaker refusal (CircuitOpenError) -> TRANSIENT: the state heals
35
60
  // 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
61
  // @see api/docs/governance/confirmations/circuit-open-classification.md
39
62
  CIRCUIT_OPEN: ErrorTypes.TRANSIENT,
40
63
 
@@ -50,7 +73,25 @@ const ErrorCodes = {
50
73
  class ErrorClassifier {
51
74
  /**
52
75
  * Classify error into type for appropriate handling
53
- *
76
+ *
77
+ * A BUSINESS CODE THIS TABLE DOES NOT KNOW IS NEITHER LABELLED NOR REFUSED (d.804).
78
+ * A name it has never heard of — `REFERENCE_RECORD_MISSING`, say — misses the `code`
79
+ * lookup below and the class comes from the next thing the error declares: `type`,
80
+ * then `code`, then `status`. So an unknown name at `422` is `VALIDATION`, exactly
81
+ * like a bare `422`, and two different unknown names with one status get one class:
82
+ * the NAME is not read at all. `error.retryable` is not read here either — it belongs
83
+ * to the error contract and is consumed elsewhere
84
+ * (`ErrorMapper.CONTRACT_RETRYABLE_CODES`, the dispatcher's `_canRetry`).
85
+ *
86
+ * That is a rule, not a gap. The dictionary of business code NAMES is not this
87
+ * package's to own: it lives in `api/docs/biz/70-contracts/error-handling.md` §1
88
+ * (BIZ-DOCS, process `error-code-dictionary` 002). A name branch here would make the
89
+ * class depend on a dictionary this package cannot see — two owners of one fact
90
+ * (`change-discipline.md` § One rail per concern).
91
+ *
92
+ * @see ../README.md § A business code this table does not know
93
+ * @see ../tests/unit/business-code-outside-the-dictionary.test.js
94
+ *
54
95
  * @param {Error} error - Error to classify
55
96
  * @returns {string} Error type from ErrorTypes enum
56
97
  */
@@ -81,30 +122,18 @@ class ErrorClassifier {
81
122
  return ErrorCodes[error.status];
82
123
  }
83
124
 
84
- // 4. Check message patterns
85
- const message = (error.message || '').toLowerCase();
86
-
87
- if (message.includes('timeout')) {
88
- return ErrorTypes.TIMEOUT;
89
- }
90
-
91
- if (message.includes('rate limit') || message.includes('rate_limit')) {
92
- return ErrorTypes.RATE_LIMIT;
93
- }
94
-
95
- if (message.includes('validation') || message.includes('invalid')) {
96
- return ErrorTypes.VALIDATION;
97
- }
98
-
99
- if (message.includes('fatal') || message.includes('critical')) {
100
- return ErrorTypes.FATAL;
101
- }
102
-
103
- if (message.includes('connection') || message.includes('network') || message.includes('econn')) {
104
- return ErrorTypes.TRANSIENT;
105
- }
106
-
107
- // 5. Default
125
+ // 4. Nothing declared it. The message is NOT consulted: it is a human
126
+ // string the error contract does not classify by, and reading it was a
127
+ // second rail beside the table above (`change-discipline.md` § One rail per
128
+ // concern). Removed in d.761 after measuring every live producer — none
129
+ // reached it: `DeliveryDispatcher._canRetry` classifies a `{ status, code }`
130
+ // with no message at all, the orchestrator sends `type`/`status`/`code` and
131
+ // routes a failed publish around this classifier entirely, and
132
+ // `conn-infra-error-handler` only delegates. The one shape it did answer
133
+ // for was a service's own internal exception, which it mis-filed: a
134
+ // `TypeError` saying "invalid" became VALIDATION, and with it the log
135
+ // severity `warn` — a defect of our code filed as a warning about input.
136
+ // An unclassified error is UNKNOWN, and UNKNOWN is not retried.
108
137
  return ErrorTypes.UNKNOWN;
109
138
  }
110
139
 
@@ -316,6 +316,14 @@ class ErrorHandlerCore {
316
316
  case ErrorTypes.FATAL:
317
317
  // Fatal errors, throw immediately
318
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' };
319
327
 
320
328
  default:
321
329
  // Unknown errors, log and throw
@@ -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