@onlineapps/mq-client-core 4.1.0 → 5.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 +766 -0
- package/README.md +226 -11
- package/package.json +1 -1
- package/src/BaseClient.js +105 -29
- package/src/config/configSchema.js +21 -4
- package/src/config/defaultConfig.js +14 -4
- package/src/config/deliveryPolicy.js +86 -6
- package/src/config/queueConfig.js +141 -60
- package/src/config/recurrencesSchema.js +121 -0
- package/src/config.js +5 -1
- package/src/defaults.js +7 -1
- package/src/index.js +67 -4
- package/src/layers/PublishLayer.js +113 -5
- package/src/monitoring-publish.js +89 -40
- package/src/scheduler-request.js +502 -0
- package/src/transports/rabbitmqClient.js +1303 -318
- package/src/utils/decodeMessage.js +123 -0
- package/src/utils/publishErrors.js +256 -17
- package/jest.config.js +0 -15
- package/jest.integration.config.js +0 -16
- package/src/workers/RecoveryWorker.js +0 -130
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* decodeMessage.js
|
|
5
|
+
*
|
|
6
|
+
* The one rail from "the broker delivered bytes" to "here is the message".
|
|
7
|
+
*
|
|
8
|
+
* `consume()` hands its handler the RAW amqplib delivery and decodes nothing for it, so
|
|
9
|
+
* every consumer on the platform wrote that translation itself. Measured in d.788: FOUR
|
|
10
|
+
* copies — `api_delivery_dispatcher` (`DeliveryDispatcher._parseMessage`), `api_scheduler`
|
|
11
|
+
* (`consumer/parseRequestMessage.js`), `@onlineapps/service-wrapper`
|
|
12
|
+
* (`_processWorkflowMessage`) and `api_services_registry` (`mqManager.consume`) — which
|
|
13
|
+
* disagree about the only thing that matters, what happens to a BAD message: one
|
|
14
|
+
* dead-letters it, one files it on a loss rail, one answers `INVALID_JSON`, and one
|
|
15
|
+
* requeues it for ever. Two of them also sniff the shape: a delivery with no `content` is
|
|
16
|
+
* passed on AS the body, so a message the strict one refuses by name travels through them
|
|
17
|
+
* as a valid one.
|
|
18
|
+
*
|
|
19
|
+
* The decoding itself was never missing — `./serializer.js` `deserialize()` has done it
|
|
20
|
+
* since the package was extracted. What was missing is the DOOR: `serializer` is not in
|
|
21
|
+
* `src/index.js`, and `src/…` is an internal path, not a contract. This module is that
|
|
22
|
+
* door plus the contract around it; it holds no second `JSON.parse`.
|
|
23
|
+
*
|
|
24
|
+
* ADDITIVE ONLY. Nothing in this package calls it yet and `consume()` is unchanged:
|
|
25
|
+
* converting the four consumers changes what happens to a malformed message, which is a
|
|
26
|
+
* BREAKING change that goes with the major wave — and, for the registry, only after its
|
|
27
|
+
* queue has a dead-letter route at all (d.784).
|
|
28
|
+
*
|
|
29
|
+
* @see ./serializer.js
|
|
30
|
+
* @see ../index.js — where it is exported
|
|
31
|
+
* @see api/docs/biz/10-invocation/mq-envelope.md — the envelope this decodes
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const { ValidationError } = require('./errorHandler');
|
|
35
|
+
const serializer = require('./serializer');
|
|
36
|
+
|
|
37
|
+
/** The one content type the platform's envelope travels as. */
|
|
38
|
+
const JSON_CONTENT_TYPE = 'application/json';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Translate one delivery into the message it carries.
|
|
42
|
+
*
|
|
43
|
+
* WHAT IT REFUSES, and with which class — both already this package's own, so a listener
|
|
44
|
+
* needs no new vocabulary:
|
|
45
|
+
*
|
|
46
|
+
* - a delivery that is not a message, carries no readable `content`, declares a content
|
|
47
|
+
* type this decoder does not read, or carries an empty body → `ValidationError`, the
|
|
48
|
+
* class this package uses for a caller's mistake, naming what arrived
|
|
49
|
+
* (`architecture-principles.md` §4 fail-fast, §5 message shape);
|
|
50
|
+
* - a body that is not JSON → the `SerializationError` `deserialize()` throws, passed
|
|
51
|
+
* through UNCHANGED, so the bytes it refused (`payload`) and the parser's own reason
|
|
52
|
+
* (`cause`) survive. Rewrapping it would be a second rail over one failure.
|
|
53
|
+
*
|
|
54
|
+
* NO SHAPE-SNIFFING: an object without a body is not a body. That fallback is what lets a
|
|
55
|
+
* message nobody can read travel on as if it were the message (d.788).
|
|
56
|
+
*
|
|
57
|
+
* NO `retryable` FIELD, measured rather than forgotten: on the consume path nothing in
|
|
58
|
+
* this package reads `error.retryable` — the fate of a message is decided by the caller's
|
|
59
|
+
* injected `classify(error)`, and without one every error is `transient`
|
|
60
|
+
* (`../config/deliveryPolicy.js` § `defaultClassification`). A field no consumer reads is
|
|
61
|
+
* a declaration without one. What a caller keys on is the error's CLASS, which is
|
|
62
|
+
* exported and stable.
|
|
63
|
+
*
|
|
64
|
+
* The content type is checked only when the message DECLARES one: the envelope contract
|
|
65
|
+
* does not require the property, so demanding it here would be a change of that contract
|
|
66
|
+
* rather than one rail for the four parsers.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} raw - The delivery as `consume()` hands it over.
|
|
69
|
+
* @param {Buffer|string} raw.content - The body.
|
|
70
|
+
* @param {Object} [raw.properties] - AMQP properties, `headers` and `contentType` included.
|
|
71
|
+
* @param {Object} [raw.fields] - AMQP delivery fields (`deliveryTag`, `redelivered`, …).
|
|
72
|
+
* @returns {{body: Object, headers: Object|undefined, properties: Object|undefined, fields: Object|undefined}}
|
|
73
|
+
* The decoded body, and everything else exactly as it arrived.
|
|
74
|
+
* @throws {ValidationError} When the delivery cannot be read at all.
|
|
75
|
+
* @throws {SerializationError} When the body is not JSON — thrown by `./serializer.js`.
|
|
76
|
+
*/
|
|
77
|
+
function decodeMessage(raw) {
|
|
78
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
79
|
+
throw new ValidationError(
|
|
80
|
+
`[mq-client-core] decodeMessage: the delivery is not a message - got ${Array.isArray(raw) ? 'array' : typeof raw}. `
|
|
81
|
+
+ 'Expected: the AMQP delivery consume() hands a handler, carrying its body in `content`. '
|
|
82
|
+
+ 'Fix: pass the message the handler received, unchanged; this function reads no other shape.'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { content, properties, fields } = raw;
|
|
87
|
+
|
|
88
|
+
if (!Buffer.isBuffer(content) && typeof content !== 'string') {
|
|
89
|
+
throw new ValidationError(
|
|
90
|
+
'[mq-client-core] decodeMessage: the delivery carries no readable content - '
|
|
91
|
+
+ `Expected: \`content\` as a Buffer or a string, got ${content === undefined ? 'nothing' : typeof content}. `
|
|
92
|
+
+ 'Fix: publish the message with a JSON body; a delivery without one is not decoded into '
|
|
93
|
+
+ 'itself, because an object that is not a body must not travel on as one.'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (content.length === 0) {
|
|
98
|
+
throw new ValidationError(
|
|
99
|
+
'[mq-client-core] decodeMessage: the delivery carries an empty body - '
|
|
100
|
+
+ 'Expected: bytes holding a JSON message. '
|
|
101
|
+
+ 'Fix: correct the producer; an empty body names no message and is not decoded into an empty one.'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const declaredType = properties && properties.contentType;
|
|
106
|
+
|
|
107
|
+
if (declaredType !== undefined && declaredType !== null && declaredType !== JSON_CONTENT_TYPE) {
|
|
108
|
+
throw new ValidationError(
|
|
109
|
+
`[mq-client-core] decodeMessage: the message declares content type "${declaredType}" - `
|
|
110
|
+
+ `Expected: "${JSON_CONTENT_TYPE}", the type the platform envelope travels as, or no `
|
|
111
|
+
+ 'declaration at all. '
|
|
112
|
+
+ 'Fix: publish the body as JSON, or decode this message with the reader its own type needs.'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The one decoding rail of this package. A second JSON.parse here would be the copy
|
|
117
|
+
// this module exists to end.
|
|
118
|
+
const body = serializer.deserialize(content);
|
|
119
|
+
|
|
120
|
+
return { body, headers: properties && properties.headers, properties, fields };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { decodeMessage, JSON_CONTENT_TYPE };
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* These errors umožní čisté oddělení publish vrstvy a recovery workeru.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
const { IllegalOperationError } = require('amqplib');
|
|
9
|
+
|
|
10
|
+
const { ConnectionError, PublishError } = require('./errorHandler');
|
|
11
|
+
|
|
8
12
|
class TransientPublishError extends Error {
|
|
9
13
|
/**
|
|
10
14
|
* @param {string} message - Human readable message
|
|
@@ -154,15 +158,254 @@ function missingQueueDefinitionMessage(queueName, isInfrastructure) {
|
|
|
154
158
|
+ 'Fix: add the template to src/config/queueConfig.js, or consume the name the owning service declares.';
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* amqplib's channel-level `NOT_FOUND`, as a NUMBER — that is how the library sets it
|
|
164
|
+
* (`err.code === 404`, measured against the dev broker beside `classId`/`methodId`).
|
|
165
|
+
*
|
|
166
|
+
* Exported since d.685a, because the publish path asks the same question in three places:
|
|
167
|
+
* the queue-existence pre-check types it as `QueueNotFoundError`, the consumer's
|
|
168
|
+
* infrastructure-queue check as `ConsumeError`, and a SEND the broker answered with it
|
|
169
|
+
* means the exchange was never declared (d.693). One literal, one owner
|
|
170
|
+
* (`change-discipline.md` § One rail per concern) — the number was spelled three more
|
|
171
|
+
* times as a bare `404` in `transports/rabbitmqClient.js` until d.693.
|
|
172
|
+
*/
|
|
173
|
+
const AMQP_NOT_FOUND = 404;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* amqplib's channel-level `PRECONDITION_FAILED`, as a NUMBER — the same shape
|
|
177
|
+
* `AMQP_NOT_FOUND` has, and set the same way (`err.code === 406` on the close frame the
|
|
178
|
+
* broker puts on the channel's `error` event).
|
|
179
|
+
*
|
|
180
|
+
* Exported since d.698, when the publisher channel's `error` handler stopped deciding
|
|
181
|
+
* from the frame's SENTENCE. What that handler really meets is measured, live:
|
|
182
|
+
*
|
|
183
|
+
* Channel closed by server: 406 (PRECONDITION-FAILED) with message
|
|
184
|
+
* "PRECONDITION_FAILED - unknown delivery tag 999999" → code 406, classId 60
|
|
185
|
+
*
|
|
186
|
+
* The number is what says "the broker answered about THIS request"; the wording around it
|
|
187
|
+
* belongs to the broker and can be re-worded without warning. It lives here beside its
|
|
188
|
+
* sibling because a second spelling of a reply code somewhere else is a second owner of
|
|
189
|
+
* the same fact (`change-discipline.md` § One rail per concern).
|
|
190
|
+
*/
|
|
191
|
+
const AMQP_PRECONDITION_FAILED = 406;
|
|
192
|
+
|
|
157
193
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
194
|
+
* The message a publisher gets when the channel it was sending on was closed by SOMEBODY
|
|
195
|
+
* ELSE'S refusal.
|
|
196
|
+
*
|
|
197
|
+
* A ConfirmChannel carries more than one unconfirmed publish at a time, and a numbered
|
|
198
|
+
* close frame is an answer about exactly ONE of them. amqplib answers all of them with
|
|
199
|
+
* the same close error (the `Channel` constructor's `'close'` listener, `lib/channel.js`, through `toClosed`), so the frame reaches
|
|
200
|
+
* sends the broker never said a word about — and until d.723 each of them was read as its
|
|
201
|
+
* own refusal. Measured 2026-09-20 in a vhost of its own, two publishes on one channel:
|
|
202
|
+
* one to an exchange nobody declared (the real refusal) and one to `good.fanout`, which
|
|
203
|
+
* the same client had declared a moment earlier. Both came back
|
|
204
|
+
* `PermanentPublishError: … Cannot publish to exchange "good.fanout": … 404 NOT-FOUND -
|
|
205
|
+
* nothing declares it`, while the broker's own sentence underneath named
|
|
206
|
+
* `no exchange 'no.such.exchange'`. The second message was dropped on a refusal that was
|
|
207
|
+
* not about it, and its publisher was sent to declare an exchange that existed.
|
|
208
|
+
*
|
|
209
|
+
* So this sentence says what happened instead: the send was not answered, the channel it
|
|
210
|
+
* was on went away under it, and the message is to be published again — which is what
|
|
211
|
+
* `PublishLayer` does with a `TransientPublishError`. `cause` carries the frame, so the
|
|
212
|
+
* refusal that DID happen stays readable.
|
|
213
|
+
*
|
|
214
|
+
* @param {'queue'|'exchange'} kind - What `target` is.
|
|
215
|
+
* @param {string} target - The queue or exchange THIS publish was addressed to.
|
|
216
|
+
* @param {number} code - The broker's reply code on the frame that closed the channel.
|
|
217
|
+
* @returns {string}
|
|
218
|
+
*/
|
|
219
|
+
function collateralCloseMessage(kind, target, code) {
|
|
220
|
+
if (kind !== 'queue' && kind !== 'exchange') {
|
|
221
|
+
throw new Error(
|
|
222
|
+
'[publishErrors] collateralCloseMessage needs to know what it is naming - '
|
|
223
|
+
+ `Expected: 'queue' or 'exchange', got ${JSON.stringify(kind)}. `
|
|
224
|
+
+ 'Fix: pass the leg the publish took — sendToQueue names a queue, publish names an exchange.'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (typeof target !== 'string' || target === '') {
|
|
229
|
+
throw new Error(
|
|
230
|
+
'[publishErrors] collateralCloseMessage requires the name the publish was addressed to - '
|
|
231
|
+
+ `Expected: a non-empty ${kind} name, got ${JSON.stringify(target)}. `
|
|
232
|
+
+ 'Fix: pass the name the caller published to.'
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (typeof code !== 'number') {
|
|
237
|
+
throw new Error(
|
|
238
|
+
'[publishErrors] collateralCloseMessage requires the broker reply code as a number - '
|
|
239
|
+
+ `Expected: the numeric code amqplib puts on the close frame, got ${JSON.stringify(code)}. `
|
|
240
|
+
+ 'Fix: read it from the channel\'s recorded close error (_closeError.code); without a number '
|
|
241
|
+
+ 'there is no refusal to be collateral of.'
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return `[RabbitMQClient] The publish to ${kind} "${target}" was collateral of a channel close caused `
|
|
246
|
+
+ `by another publish - the broker answered ${code} about a different send on the same channel, and `
|
|
247
|
+
+ `said nothing about this ${kind}. `
|
|
248
|
+
+ 'Expected: a refusal is an answer about ONE message; the others on that channel were never '
|
|
249
|
+
+ 'answered at all, so they were not refused and were not stored either. '
|
|
250
|
+
+ "Fix: nothing here — the message is republished on the channel's successor (PublishLayer retries "
|
|
251
|
+
+ 'a transient publish and buffers it meanwhile). Read error.cause for the refusal that really '
|
|
252
|
+
+ 'happened, and fix THAT one.';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The message a publisher gets when the broker REFUSED the send and closed the channel
|
|
257
|
+
* with a numbered frame.
|
|
258
|
+
*
|
|
259
|
+
* ONE producer for both statements, because they differ in exactly one thing — what the
|
|
260
|
+
* number means — and writing that difference as a ternary at the construction site puts
|
|
261
|
+
* the whole message where no test can reach it. It is the shape
|
|
262
|
+
* `missingQueueDefinitionMessage()` above already has, and the runtime half of
|
|
263
|
+
* `tests/unit/error-message-contract.test.js` asserts these strings beside the others.
|
|
264
|
+
*
|
|
265
|
+
* - `404 NOT-FOUND` (d.693) says nobody DECLARED what was published to. A publisher
|
|
266
|
+
* declares nothing — exchanges come into being from `config/queueConfig.js`, asserted
|
|
267
|
+
* by the service that owns them — so this is the same answer `QueueNotFoundError`'s
|
|
268
|
+
* `unowned` branch gives for a queue name nothing owns, about the other object;
|
|
269
|
+
* - any other number (`403 ACCESS-REFUSED`, `406 PRECONDITION-FAILED`, measured) says
|
|
270
|
+
* the broker refused THIS message.
|
|
271
|
+
*
|
|
272
|
+
* Both are permanent for one reason: the refusal is an answer about this request, and
|
|
273
|
+
* repeating the request repeats it.
|
|
274
|
+
*
|
|
275
|
+
* @param {'queue'|'exchange'} kind - What `target` is, so the sentence names it correctly.
|
|
276
|
+
* Required and checked: a publish names one or the other, never "something".
|
|
277
|
+
* @param {string} target - The queue or exchange the publish was addressed to.
|
|
278
|
+
* @param {number} code - The broker's reply code, as amqplib sets it (a NUMBER).
|
|
279
|
+
* @returns {string}
|
|
280
|
+
*/
|
|
281
|
+
function refusedSendMessage(kind, target, code) {
|
|
282
|
+
if (kind !== 'queue' && kind !== 'exchange') {
|
|
283
|
+
throw new Error(
|
|
284
|
+
'[publishErrors] refusedSendMessage needs to know what it is naming - '
|
|
285
|
+
+ `Expected: 'queue' or 'exchange', got ${JSON.stringify(kind)}. `
|
|
286
|
+
+ 'Fix: pass the leg the publish took — sendToQueue names a queue, publish names an exchange.'
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (typeof target !== 'string' || target === '') {
|
|
291
|
+
throw new Error(
|
|
292
|
+
'[publishErrors] refusedSendMessage requires the name the publish was addressed to - '
|
|
293
|
+
+ `Expected: a non-empty ${kind} name, got ${JSON.stringify(target)}. `
|
|
294
|
+
+ 'Fix: pass the name the caller published to.'
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (typeof code !== 'number') {
|
|
299
|
+
throw new Error(
|
|
300
|
+
'[publishErrors] refusedSendMessage requires the broker reply code as a number - '
|
|
301
|
+
+ `Expected: the numeric code amqplib puts on the close frame, got ${JSON.stringify(code)}. `
|
|
302
|
+
+ 'Fix: read it from the channel\'s recorded close error (_closeError.code); a refusal with no '
|
|
303
|
+
+ 'number is not a refusal, it is a channel death, and that one is transient.'
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (code === AMQP_NOT_FOUND) {
|
|
308
|
+
return `[RabbitMQClient] Cannot publish to ${kind} "${target}": the broker closed the channel with `
|
|
309
|
+
+ '404 NOT-FOUND - nothing declares it. '
|
|
310
|
+
+ `Expected: every ${kind} to come into being from a declaration — `
|
|
311
|
+
+ 'src/config/queueConfig.js declares it and the service that owns it asserts exactly that '
|
|
312
|
+
+ '(assertExchange() for an exchange, setupServiceQueues() for a queue). A publisher declares '
|
|
313
|
+
+ 'nothing, so a name nobody declared has nothing to route with and the message was NOT stored. '
|
|
314
|
+
+ `Fix: add the ${kind} to src/config/queueConfig.js (confirmation mq-consumer-contract 003) and `
|
|
315
|
+
+ `let its owner declare it, or publish to a ${kind} that already has one. No reconnect creates it, `
|
|
316
|
+
+ 'so this does not clear by retrying.';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return `[RabbitMQClient] The broker refused the publish to ${kind} "${target}" with ${code} and closed `
|
|
320
|
+
+ 'the channel - Expected: a refusal is an answer about THIS message, so repeating the publish '
|
|
321
|
+
+ 'repeats it; the message was NOT stored. '
|
|
322
|
+
+ "Fix: read error.cause for the broker's own sentence — 403 means the account behind RABBITMQ_URL "
|
|
323
|
+
+ 'has no write permission in this vhost, 406 means the message itself was refused (a user_id '
|
|
324
|
+
+ 'property that is not the authenticated user, for instance). Neither clears by retrying.';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Klasifikace chyb z publishu do specializovaných typů — PODLE TŘÍDY A KÓDU,
|
|
329
|
+
* nikdy podle textu hlášky.
|
|
330
|
+
*
|
|
331
|
+
* Its verdict is a decision about the message, not a label: `PublishLayer` retries a
|
|
332
|
+
* `TransientPublishError` and fails a `PermanentPublishError` or a
|
|
333
|
+
* `QueueNotFoundError` immediately (`PublishLayer._publishWithRetry()`). Until d.677
|
|
334
|
+
* that decision was read out of substrings of the message — `'queue does not exist'`,
|
|
335
|
+
* `'no queue'`, `'connection closing'`, `'connection closed'`, `'connection ended'`,
|
|
336
|
+
* `'channel closed'`, `'channel is closed'`, `'channel ended'`, `'timeout'`,
|
|
337
|
+
* `'confirmation timeout'`, `'reconnecting'` — so a reworded sentence moved a message
|
|
338
|
+
* from "retried and buffered" to "dropped", and nothing reported the move. The
|
|
339
|
+
* contract says the opposite: `docs/biz/70-contracts/error-handling.md` §6
|
|
340
|
+
* ("Message-based routing; breaks on any wording change · Instead: Branch on
|
|
341
|
+
* `err.code`").
|
|
342
|
+
*
|
|
343
|
+
* WHAT DECIDES, and what each carrier stands for (measured d.677 against the dev
|
|
344
|
+
* broker, each scenario in its own vhost; the live table is
|
|
345
|
+
* `tests/integration/publish-classification-carriers.integration.test.js`):
|
|
346
|
+
* - an error this package already typed — handed back untouched, because the publish
|
|
347
|
+
* path decided it with facts this function does not have (which section of
|
|
348
|
+
* `queueConfig` owns the name, above all);
|
|
349
|
+
* - `code === 404` — amqplib's `NOT_FOUND`: the queue is not there;
|
|
350
|
+
* - `IllegalOperationError` — amqplib's own class for a channel or connection used
|
|
351
|
+
* after it died, exported from amqplib's entry point;
|
|
352
|
+
* - `ConnectionError` / `PublishError` — what the publish path itself builds when the
|
|
353
|
+
* channel is gone at send time (`ConnectionError`), and when the confirm never
|
|
354
|
+
* arrives, when the channel closed mid-publish and when the delivery tag no longer
|
|
355
|
+
* exists (`PublishError`, its five sites). Every one of them means "this attempt
|
|
356
|
+
* did not get an answer", which is what a retry is for;
|
|
357
|
+
* - everything else — permanent. Its measured members are a broker refusal OTHER
|
|
358
|
+
* than 404 that the existence pre-check re-throws (`405 RESOURCE_LOCKED`,
|
|
359
|
+
* `Operation failed: QueueDeclare; 405 …`, `code: 405`), a serialization failure,
|
|
360
|
+
* and a broker nack (`'message nacked'`). A refusal is an answer about THIS
|
|
361
|
+
* request, and repeating the request repeats the answer — which is also the
|
|
362
|
+
* verdict every one of them has today.
|
|
363
|
+
*
|
|
364
|
+
* NOT ONE BRANCH READS THE SENTENCE — since d.683, not even by exact equality. Until
|
|
365
|
+
* then two carriers had to be matched by their text, because they carry nothing else:
|
|
366
|
+
* amqplib answers every unconfirmed publish of a channel that has just closed with
|
|
367
|
+
* `new Error('channel closed')` (the `Channel` constructor's `'close'` listener, `node_modules/amqplib/lib/channel.js`) and every
|
|
368
|
+
* RPC queued on a channel that ended with
|
|
369
|
+
* `new Error('Channel ended, no reply will be forthcoming')` (`_rejectPending()`, `lib/channel.js`) —
|
|
370
|
+
* own properties `stack` and `message`, no class, no code. They are now typed where
|
|
371
|
+
* they ARRIVE, by the two sites that know which channel died: `_publishOnce()`'s
|
|
372
|
+
* exchange callback answers with this package's `PublishError` and the queue-existence
|
|
373
|
+
* pre-check with `_publishPathTransient()`, each keeping amqplib's object in `cause`.
|
|
374
|
+
* So a bare sentence reaching THIS function means it got past a typing site, which is
|
|
375
|
+
* a defect to find rather than a message to retry — and it is answered like any other
|
|
376
|
+
* untyped error.
|
|
377
|
+
*
|
|
378
|
+
* WHAT DOES NOT ARRIVE, AND WHY IT NO LONGER MATTERS: the broker's numbered channel error
|
|
379
|
+
* for a refused SEND never reaches this function first-hand. That object goes to the
|
|
380
|
+
* channel's `error` event, while the publish callback is answered by amqplib's bare
|
|
381
|
+
* sentinel. Until d.685a the send legs answered it with their own `PublishError`, so a
|
|
382
|
+
* 403 or a 406 — a refusal of THIS message — was classified transient, retried
|
|
383
|
+
* `publishMaxRetries` times and then buffered, to be re-offered on every reconnect. Since
|
|
384
|
+
* d.685a both send legs read the close frame their own channel recorded
|
|
385
|
+
* (`_refusedSendPermanent()`, `src/transports/rabbitmqClient.js`) and hand over a
|
|
386
|
+
* `PermanentPublishError` with the broker's object in `cause` — which arrives here
|
|
387
|
+
* already typed and is passed through by the first rule. Since d.693 that includes the
|
|
388
|
+
* send's own `404` (an exchange nobody declared, carrier B1): it is permanent for the
|
|
389
|
+
* same reason, because no reconnect declares an exchange. What it is NOT is a
|
|
390
|
+
* `QueueNotFoundError` — that class belongs to the queue-existence pre-check, carries a
|
|
391
|
+
* `queueName` and an ownership `kind`, and still hands over the coded 404 and 405 it
|
|
392
|
+
* always did.
|
|
393
|
+
*
|
|
394
|
+
* NO SOCKET CODES HERE. `ECONNREFUSED`/`ENOTFOUND`/`ETIMEDOUT` never reach this
|
|
395
|
+
* function: `_ensurePublisherChannel()` runs BEFORE the `try` that calls it
|
|
396
|
+
* (`src/transports/rabbitmqClient.js`), so a publish with no connection fails above.
|
|
397
|
+
* A branch nothing reaches is a false guarantee (`automation-gates.md` §5).
|
|
398
|
+
*
|
|
399
|
+
* The cause chain is deliberately NOT walked: this function sits at the BOTTOM of the
|
|
400
|
+
* publish path — it is what builds the chain that `BaseClient.publish()` and
|
|
401
|
+
* `isQueueUnavailableError()` later read — and every carrier measured at this point is
|
|
402
|
+
* first-hand.
|
|
160
403
|
*
|
|
161
404
|
* The queue name comes from the CALLER, and is required. The missing-queue branch
|
|
162
405
|
* below used to invent it — `new QueueNotFoundError('unknown', …)` — and the
|
|
163
|
-
* invented name does not stay in the
|
|
164
|
-
*
|
|
165
|
-
*
|
|
406
|
+
* invented name does not stay in the error: `BaseClient.publish()` rejects the caller
|
|
407
|
+
* with a `PublishError` whose `cause` is this error, so the caller would read
|
|
408
|
+
* `error.cause.queueName === 'unknown'`, naming nothing anybody can act on. Every
|
|
166
409
|
* caller has the real name in hand (`_publishOnce(queue, …)`,
|
|
167
410
|
* `publishToMonitoringResilient(…, queueName, …)`), so it is asked for at entry
|
|
168
411
|
* (`architecture-principles.md` §4) rather than guessed in one branch (§3, and the
|
|
@@ -195,10 +438,8 @@ function classifyPublishError(err, queueName) {
|
|
|
195
438
|
return err;
|
|
196
439
|
}
|
|
197
440
|
|
|
198
|
-
const message = (err.message || String(err)).toLowerCase();
|
|
199
|
-
|
|
200
441
|
// Queue neexistuje – trvalý problém, nerozbitný retry
|
|
201
|
-
if (
|
|
442
|
+
if (err.code === AMQP_NOT_FOUND) {
|
|
202
443
|
// Which section of queueConfig owns the name is not known here — that lookup
|
|
203
444
|
// belongs to the publish path, which asks it before this classification is ever
|
|
204
445
|
// reached — so the error carries the name and `kind: 'unknown'`, whose message
|
|
@@ -208,15 +449,9 @@ function classifyPublishError(err, queueName) {
|
|
|
208
449
|
|
|
209
450
|
// Typicky transientní problémy connection/channel
|
|
210
451
|
if (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
message.includes('channel closed') ||
|
|
215
|
-
message.includes('channel is closed') ||
|
|
216
|
-
message.includes('channel ended') ||
|
|
217
|
-
message.includes('timeout') ||
|
|
218
|
-
message.includes('confirmation timeout') ||
|
|
219
|
-
message.includes('reconnecting')
|
|
452
|
+
err instanceof IllegalOperationError ||
|
|
453
|
+
err instanceof ConnectionError ||
|
|
454
|
+
err instanceof PublishError
|
|
220
455
|
) {
|
|
221
456
|
return new TransientPublishError(err.message || String(err), err);
|
|
222
457
|
}
|
|
@@ -226,8 +461,12 @@ function classifyPublishError(err, queueName) {
|
|
|
226
461
|
}
|
|
227
462
|
|
|
228
463
|
module.exports = {
|
|
464
|
+
AMQP_NOT_FOUND,
|
|
465
|
+
AMQP_PRECONDITION_FAILED,
|
|
466
|
+
collateralCloseMessage,
|
|
229
467
|
missingInfrastructureQueueMessage,
|
|
230
468
|
missingQueueDefinitionMessage,
|
|
469
|
+
refusedSendMessage,
|
|
231
470
|
TransientPublishError,
|
|
232
471
|
PermanentPublishError,
|
|
233
472
|
QueueNotFoundError,
|
package/jest.config.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Default tier: unit + component only.
|
|
5
|
-
*
|
|
6
|
-
* `tests/integration` is excluded here and served by `jest.integration.config.js`
|
|
7
|
-
* instead, because that tier fails fast on a missing `RABBITMQ_URL` — folding it
|
|
8
|
-
* into the default run would make `npm test` unrunnable without a broker, and the
|
|
9
|
-
* usual "fix" for that is a skip, which is how a suite quietly leaves the
|
|
10
|
-
* regression loop (`.claude/rules/service-refactoring.md` #4).
|
|
11
|
-
*/
|
|
12
|
-
module.exports = {
|
|
13
|
-
testEnvironment: 'node',
|
|
14
|
-
testPathIgnorePatterns: ['/node_modules/', '/tests/integration/']
|
|
15
|
-
};
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Integration tier: runs against the live broker, never skipped.
|
|
5
|
-
*
|
|
6
|
-
* `globalSetup` aborts the whole run before Jest builds a describe tree when
|
|
7
|
-
* `RABBITMQ_URL` is missing or the broker is unreachable — see
|
|
8
|
-
* `tests/integration/setup.js`.
|
|
9
|
-
*/
|
|
10
|
-
module.exports = {
|
|
11
|
-
testEnvironment: 'node',
|
|
12
|
-
testMatch: ['**/tests/integration/**/*.test.js'],
|
|
13
|
-
testTimeout: 30000,
|
|
14
|
-
globalSetup: './tests/integration/setup.js',
|
|
15
|
-
verbose: true
|
|
16
|
-
};
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { QueueNotFoundError } = require('../utils/publishErrors');
|
|
4
|
-
const { ValidationError } = require('../utils/errorHandler');
|
|
5
|
-
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* RecoveryWorker — odpovídá na problémy publish cesty (connection recovery,
|
|
9
|
-
* hlášení chybějící fronty).
|
|
10
|
-
*
|
|
11
|
-
* Zakládání front — NEZAKLÁDÁ ŽÁDNOU. Fronta vzniká výhradně z deklarace:
|
|
12
|
-
* infrastrukturní ji zakládá vlastnící infra služba při bootu, business ji
|
|
13
|
-
* zakládá vlastnící služba přes `setupServiceQueues()` nad šablonou z
|
|
14
|
-
* `queueConfig` po registraci, a jméno, které `queueConfig` neklasifikuje ani
|
|
15
|
-
* jako jedno, nezakládá nikdo. Rozhodnutí vlastníka
|
|
16
|
-
* (`docs/governance/confirmations/mq-consumer-contract.md` 006) říká obojí
|
|
17
|
-
* větou: *„queues come into being only from declarations"* a *„A future family
|
|
18
|
-
* of queues that a service must create ad hoc gets a `queueConfig` template
|
|
19
|
-
* first, never a generic create-anything method."*
|
|
20
|
-
*
|
|
21
|
-
* Do d.419 tady byly DRUHÉ dveře k těm, které publish cesta v témže kroku
|
|
22
|
-
* zavírala: `createQueue()` a delegující `queueCreationCallback` zakládaly
|
|
23
|
-
* frontu bez vlastníka s `durable=true, arguments={}` — bez TTL a bez cesty pro
|
|
24
|
-
* nedoručitelné zprávy. Taková fronta je od d.259 nekonzumovatelná
|
|
25
|
-
* (`consume()` odmítne frontu, které konfigurace žádnou cestu nedeklaruje), takže
|
|
26
|
-
* ty dveře vyráběly fronty, do kterých lze publikovat a nelze z nich číst.
|
|
27
|
-
* Čtyři otázky (`change-discipline.md` § Removing something removes its
|
|
28
|
-
* declaration) jsou zodpovězené v CHANGELOGu dávky.
|
|
29
|
-
*
|
|
30
|
-
* `scope` tedy už o zakládání front nerozhodoval; od d.419 neříkal nic a od d.514
|
|
31
|
-
* tu není. Co rozhoduje o tvaru hlášení, je třída vlastnictví, kterou odpověděla
|
|
32
|
-
* CENTRÁLNÍ deklarace — `error.kind` z `QueueNotFoundError` — takže dva klienti
|
|
33
|
-
* namířené na tutéž frontu nemohou dostat různou odpověď podle toho, kdo je
|
|
34
|
-
* postavil. Čtyři otázky k odstranění jsou zodpovězené v CHANGELOGu dávky.
|
|
35
|
-
*/
|
|
36
|
-
class RecoveryWorker {
|
|
37
|
-
/**
|
|
38
|
-
* @param {Object} options
|
|
39
|
-
* @param {Object} options.client - RabbitMQClient instance
|
|
40
|
-
* @param {Object} options.logger - Logger s info/warn/error/debug (povinny)
|
|
41
|
-
*/
|
|
42
|
-
constructor(options = {}) {
|
|
43
|
-
if (!options.client) {
|
|
44
|
-
throw new ValidationError(
|
|
45
|
-
'[RecoveryWorker] options.client is required - Expected: the RabbitMQClient whose publish failures the worker answers. '
|
|
46
|
-
+ 'Fix: construct it as new RecoveryWorker({ client, logger }).'
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
this._client = options.client;
|
|
51
|
-
this._logger = assertLogger(
|
|
52
|
-
'RecoveryWorker',
|
|
53
|
-
options.logger,
|
|
54
|
-
'the worker reports every recovery decision it makes about a queue that does not exist',
|
|
55
|
-
'pass options.logger (the transport forwards the one your service built)'
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Handle transient publish error
|
|
61
|
-
* @param {Error} error - TransientPublishError
|
|
62
|
-
* @param {Object} context - { queue, buffer, options }
|
|
63
|
-
*/
|
|
64
|
-
async handleTransientError(error, context) {
|
|
65
|
-
// Transient errors jsou už bufferované v PublishLayer
|
|
66
|
-
// Recovery worker jen loguje a může triggerovat další akce
|
|
67
|
-
this._logger.warn(`[RecoveryWorker] Transient error handled (buffered): ${error.message}`, context);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Report a queue that does not exist, and rethrow.
|
|
72
|
-
*
|
|
73
|
-
* Every class of name ends the same way — the worker creates nothing — and the
|
|
74
|
-
* three branches differ only in which rule the reader has to act on: who owns
|
|
75
|
-
* the queue, and how it is meant to come into being.
|
|
76
|
-
*
|
|
77
|
-
* @param {QueueNotFoundError} error
|
|
78
|
-
* @param {Object} context - { queue, buffer, options }
|
|
79
|
-
*/
|
|
80
|
-
async handleQueueNotFound(error, context) {
|
|
81
|
-
if (!(error instanceof QueueNotFoundError)) {
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Infrastructure queues must exist - cannot create
|
|
86
|
-
if (error.isInfrastructure) {
|
|
87
|
-
this._logger.error(`[RecoveryWorker] Cannot create infrastructure queue '${error.queueName}' - must exist before publishing`);
|
|
88
|
-
throw error;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Business queues ({service}.{workflow|queue|dlq}) must exist too — they are
|
|
92
|
-
// created by their owning service via setupServiceQueues() AFTER registration,
|
|
93
|
-
// with the arguments queueConfig prescribes.
|
|
94
|
-
//
|
|
95
|
-
// Measured 2026-08-28: without this branch the ownership guard in
|
|
96
|
-
// `_publishOnce` was defeated by its own error path. The publish was refused
|
|
97
|
-
// correctly, the caller saw PublishError — and then this worker, listening for
|
|
98
|
-
// QueueNotFoundError on the client's error event, created the very queue the
|
|
99
|
-
// guard had just refused ("[RecoveryWorker] ✓ Created queue '<q>' (direct)"),
|
|
100
|
-
// with default arguments and no TTL. Refusing at one door while opening the
|
|
101
|
-
// other is not a guard.
|
|
102
|
-
//
|
|
103
|
-
// `recoveryScope: 'business'` said the CLIENT belongs to a business service.
|
|
104
|
-
// It never meant "may create business queues", and `ConnectorMQClient` set it
|
|
105
|
-
// for every biz service, so that reading made the rule vacuous. The key is
|
|
106
|
-
// gone with the door it guarded (d.514); what decides here is `error.kind`,
|
|
107
|
-
// which the central declaration answers.
|
|
108
|
-
if (error.kind === 'business') {
|
|
109
|
-
this._logger.error(`[RecoveryWorker] Cannot create business queue '${error.queueName}' - it is created by its owning service via setupServiceQueues() after registration`);
|
|
110
|
-
throw error;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Everything else — a name `queueConfig` classifies as neither. Until d.419
|
|
114
|
-
// this was the one branch that DID create, and it is the same "one door
|
|
115
|
-
// refuses while the other opens" the business branch above records, one class
|
|
116
|
-
// of name further out: the publish path refused the name and this handler,
|
|
117
|
-
// answering the very error that refusal raised, declared it anyway. There is
|
|
118
|
-
// no owner to start and no template to call, so the report names the only
|
|
119
|
-
// thing the reader can act on — the missing declaration.
|
|
120
|
-
this._logger.error(
|
|
121
|
-
`[RecoveryWorker] Cannot create queue '${error.queueName}' - no section of queueConfig declares it, `
|
|
122
|
-
+ 'and a queue is only ever brought into being from a declaration. Fix: add a template for this '
|
|
123
|
-
+ 'name to queueConfig and let its owner declare it (confirmation mq-consumer-contract 003), or '
|
|
124
|
-
+ 'publish to a queue that already has one.'
|
|
125
|
-
);
|
|
126
|
-
throw error;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
module.exports = RecoveryWorker;
|