@onlineapps/error-handler-core 3.1.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,81 @@ 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
+
7
82
  ## [3.1.0] — 2026-09-25
8
83
 
9
84
  ### Added — třída `ErrorTypes.DECLINED`: pravidlo práci zakázalo (d.831)
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
@@ -167,6 +188,48 @@ same status carries both meanings and only the thrower knows which one it meant.
167
188
  @see api/docs/governance/confirmations/workflow-failed-status-projection.md — 004
168
189
  @see api/docs/biz/70-contracts/error-handling.md — the error classes
169
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
+
170
233
  ## API Reference
171
234
 
172
235
  See [ERROR_HANDLING.md](/docs/standards/ERROR_HANDLING.md) for the cross-cutting standard (classification table, fail-loudly rules).
@@ -183,5 +246,5 @@ See [ERROR_HANDLING.md](/docs/standards/ERROR_HANDLING.md) for the cross-cutting
183
246
 
184
247
  ---
185
248
 
186
- *Version: 1.0.0 | License: MIT*
249
+ *License: MIT*
187
250
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/error-handler-core",
3
- "version": "3.1.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"
@@ -29,6 +29,24 @@ const ErrorCodes = {
29
29
  ETIMEDOUT: ErrorTypes.TRANSIENT,
30
30
  ECONNRESET: ErrorTypes.TRANSIENT,
31
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
+
32
50
 
33
51
  // HTTP status codes
34
52
  408: ErrorTypes.TIMEOUT,
@@ -40,8 +58,6 @@ const ErrorCodes = {
40
58
 
41
59
  // Circuit breaker refusal (CircuitOpenError) -> TRANSIENT: the state heals
42
60
  // itself once the breaker's resetTimeout elapses, so retrying is allowed.
43
- // Pinned by code, never by message wording — the word "resetTimeout" in the
44
- // fix hint would otherwise read as TIMEOUT via the patterns below.
45
61
  // @see api/docs/governance/confirmations/circuit-open-classification.md
46
62
  CIRCUIT_OPEN: ErrorTypes.TRANSIENT,
47
63
 
@@ -57,7 +73,25 @@ const ErrorCodes = {
57
73
  class ErrorClassifier {
58
74
  /**
59
75
  * Classify error into type for appropriate handling
60
- *
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
+ *
61
95
  * @param {Error} error - Error to classify
62
96
  * @returns {string} Error type from ErrorTypes enum
63
97
  */
@@ -88,30 +122,18 @@ class ErrorClassifier {
88
122
  return ErrorCodes[error.status];
89
123
  }
90
124
 
91
- // 4. Check message patterns
92
- const message = (error.message || '').toLowerCase();
93
-
94
- if (message.includes('timeout')) {
95
- return ErrorTypes.TIMEOUT;
96
- }
97
-
98
- if (message.includes('rate limit') || message.includes('rate_limit')) {
99
- return ErrorTypes.RATE_LIMIT;
100
- }
101
-
102
- if (message.includes('validation') || message.includes('invalid')) {
103
- return ErrorTypes.VALIDATION;
104
- }
105
-
106
- if (message.includes('fatal') || message.includes('critical')) {
107
- return ErrorTypes.FATAL;
108
- }
109
-
110
- if (message.includes('connection') || message.includes('network') || message.includes('econn')) {
111
- return ErrorTypes.TRANSIENT;
112
- }
113
-
114
- // 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.
115
137
  return ErrorTypes.UNKNOWN;
116
138
  }
117
139