@onlineapps/mq-client-core 4.2.0 → 5.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.
@@ -18,19 +18,66 @@
18
18
  * await publishToMonitoringWorkflow(mqClient, { event_type: 'completed', workflow_id: '...', ... }, logger);
19
19
  */
20
20
 
21
- const { PublishError, ConnectionError } = require('./utils/errorHandler');
21
+ const { ConnectionError } = require('./utils/errorHandler');
22
22
  const { assertLogger } = require('@onlineapps/logger-contract');
23
- const { QueueNotFoundError, classifyPublishError } = require('./utils/publishErrors');
23
+ const {
24
+ AMQP_NOT_FOUND,
25
+ QueueNotFoundError,
26
+ TransientPublishError
27
+ } = require('./utils/publishErrors');
24
28
 
25
29
  /**
26
- * Check if error indicates queue doesn't exist or RabbitMQ is unavailable
30
+ * The socket-level codes that mean the broker was not reachable at all. Node sets
31
+ * them, not this package, and they are codes rather than words.
32
+ */
33
+ const UNREACHABLE_SOCKET_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT']);
34
+
35
+ /**
36
+ * How deep a cause chain this function is willing to walk.
27
37
  *
28
- * The queue is named because the last question it asks —
29
- * `classifyPublishError()` — builds a `QueueNotFoundError`, and that error carries
30
- * a queue name it must be told (d.435); the caller has had it all along.
38
+ * The measured chain is three links — `PublishError` (BaseClient) → the publish
39
+ * classification → amqplib's raw error — so the bound is an upper limit on a real
40
+ * shape, not a guess about an unknown one. It exists because `error.cause` is set by
41
+ * whoever threw, and a chain that points back at itself would otherwise spin.
42
+ */
43
+ const MAX_CAUSE_DEPTH = 8;
44
+
45
+ /**
46
+ * Does this error — or anything it was caused by — mean the queue was not there to
47
+ * publish into, or the broker was not reachable?
48
+ *
49
+ * DECIDED BY CLASS AND CODE, NEVER BY THE MESSAGE. Until d.676 the decision read
50
+ * substrings: `'not connected'`, `'Connection closed'`, `'NOT_FOUND'`, `'404'`,
51
+ * `'no queue'`, `'queue does not exist'`, `'Channel closed'`. Every one of those
52
+ * sentences belongs to somebody else — amqplib writes the broker's refusal, this
53
+ * package writes its own — so the classification broke on any rewording and nothing
54
+ * reported the break (`docs/biz/70-contracts/error-handling.md` §6: "Message-based
55
+ * routing; breaks on any wording change — branch on `err.code`").
56
+ *
57
+ * WHY THE CHAIN AND NOT THE ERROR ITSELF: `BaseClient.publish()` wraps every
58
+ * transport failure in a `PublishError` and puts the real error in `cause`. So the
59
+ * object handed to this function is the wrapper, its `instanceof` tests never
60
+ * matched, and only the substrings ever fired — by accident, because the wrapper's
61
+ * message quotes the cause's sentence. Two shapes were measured answering `false`
62
+ * although the queue really was unavailable: a `QueueNotFoundError` with
63
+ * `kind: 'unowned'`, whose sentence carries none of the substrings, and a
64
+ * retry-exhausted publish, whose sentence quotes no cause at all.
65
+ *
66
+ * The carriers this walks for, and what each stands for:
67
+ * - `QueueNotFoundError` — the publish path's own ownership refusal (the queue is
68
+ * absent, whichever owner should have created it);
69
+ * - `ConnectionError` — the client is not connected, or the connection dropped
70
+ * (`CONNECTION_CLOSED_UNEXPECTEDLY`);
71
+ * - `TransientPublishError` — the channel or connection went away mid-publish;
72
+ * amqplib's `IllegalOperationError` ("Channel closed"), which carries no code of
73
+ * its own, reaches here as exactly this class;
74
+ * - `code === 404` — amqplib's raw `NOT_FOUND`;
75
+ * - `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` — the broker was unreachable.
31
76
  *
32
77
  * @param {Error} error - The error a publish to `queueName` produced.
33
- * @param {string} queueName - The queue that publish was aimed at.
78
+ * @param {string} queueName - The queue that publish was aimed at. Required since
79
+ * d.435 and part of the exported contract; it is what the refusal below names.
80
+ * @returns {boolean}
34
81
  * @private
35
82
  */
36
83
  function isQueueUnavailableError(error, queueName) {
@@ -43,39 +90,21 @@ function isQueueUnavailableError(error, queueName) {
43
90
  );
44
91
  }
45
92
 
46
- if (!error) return false;
47
-
48
- const errorMessage = error.message || String(error);
49
- const errorCode = error.code || '';
50
- const errorName = error.name || '';
51
-
52
- // Connection errors
53
- if (error instanceof ConnectionError ||
54
- errorCode === 'ECONNREFUSED' ||
55
- errorCode === 'ENOTFOUND' ||
56
- errorCode === 'ETIMEDOUT' ||
57
- errorMessage.includes('not connected') ||
58
- errorMessage.includes('Connection closed')) {
59
- return true;
60
- }
61
-
62
- // Queue doesn't exist errors
63
- if (error instanceof QueueNotFoundError ||
64
- errorName === 'QueueNotFoundError' ||
65
- errorMessage.includes('NOT_FOUND') ||
66
- errorMessage.includes('404') ||
67
- errorMessage.includes('no queue') ||
68
- errorMessage.includes('queue does not exist') ||
69
- errorMessage.includes('Channel closed')) {
70
- return true;
71
- }
72
-
73
- // Classify publish errors
74
- const classified = classifyPublishError(error, queueName);
75
- if (classified instanceof QueueNotFoundError) {
76
- return true;
93
+ let link = error;
94
+
95
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && link instanceof Error; depth += 1) {
96
+ if (link instanceof QueueNotFoundError
97
+ || link instanceof ConnectionError
98
+ || link instanceof TransientPublishError
99
+ || link.code === AMQP_NOT_FOUND
100
+ || UNREACHABLE_SOCKET_CODES.has(link.code)) {
101
+ return true;
102
+ }
103
+
104
+ if (link.cause === link) break;
105
+ link = link.cause;
77
106
  }
78
-
107
+
79
108
  return false;
80
109
  }
81
110
 
@@ -115,6 +144,26 @@ async function publishToMonitoringResilient(mqClient, queueName, message, logger
115
144
  return false;
116
145
  }
117
146
 
147
+ // The client's own events go out on the client's own channel (d.724). Said here, in the
148
+ // one place that knows these messages are the client talking about itself; `publish()`
149
+ // would put them on the channel carrying the caller's business messages, where a
150
+ // refusal of an event closes the channel and takes those with it (measured d.711).
151
+ // Reported and skipped rather than thrown, like every other precondition in this
152
+ // function: it is fail-safe about publishing by contract (§ Principles above), and a
153
+ // transport without the method would otherwise turn a monitoring event into a caller's
154
+ // exception.
155
+ if (typeof mqClient.publishTelemetry !== 'function') {
156
+ logger.warn(
157
+ `Monitoring publish skipped: the client has no publishTelemetry() (queue: ${queueName}) - `
158
+ + 'Expected: a client whose telemetry has a channel of its own, so a refused event cannot '
159
+ + 'close the channel a business message is waiting on. '
160
+ + 'Fix: pass a @onlineapps/mq-client-core BaseClient, or a client that forwards '
161
+ + 'publishTelemetry() to one.',
162
+ context
163
+ );
164
+ return false;
165
+ }
166
+
118
167
  if (!message || !message.event_type) {
119
168
  logger.warn(`Monitoring publish skipped: message missing event_type (queue: ${queueName})`, {
120
169
  ...context,
@@ -140,7 +189,7 @@ async function publishToMonitoringResilient(mqClient, queueName, message, logger
140
189
  const publishOptions = queueName === 'monitoring.workflow'
141
190
  ? { exchange: WORKFLOW_FANOUT, routingKey: '' }
142
191
  : {};
143
- await mqClient.publish(queueName, message, publishOptions);
192
+ await mqClient.publishTelemetry(queueName, message, publishOptions);
144
193
 
145
194
  logger.debug(`Published to ${queueName}`, {
146
195
  ...context,
@@ -81,7 +81,7 @@ const describeErrors = (errors) => (errors || [])
81
81
  */
82
82
  function validateRecurrences(document, source) {
83
83
  if (typeof source !== 'string' || source.trim() === '') {
84
- throw new Error(
84
+ throw invalidRequest(
85
85
  '[mq-client-core/scheduler-request] Missing source - Expected: a name for what is being '
86
86
  + 'checked, because every refusal below quotes it and a wrong name sends the reader to the '
87
87
  + 'wrong place. Fix: pass what the caller calls this document (e.g. "the declaration").'
@@ -89,7 +89,7 @@ function validateRecurrences(document, source) {
89
89
  }
90
90
 
91
91
  if (!document || typeof document !== 'object' || Array.isArray(document)) {
92
- throw new Error(
92
+ throw invalidRequest(
93
93
  `[mq-client-core/scheduler-request] ${source} is not a declaration - Expected: an object with a "recurrences" list, `
94
94
  + `got ${Array.isArray(document) ? 'an array' : typeof document}. `
95
95
  + 'Fix: write { "recurrences": [ … ] }, or remove the file if the service declares none.'
@@ -97,7 +97,7 @@ function validateRecurrences(document, source) {
97
97
  }
98
98
 
99
99
  if (!validateDeclaration(document)) {
100
- throw new Error(
100
+ throw invalidRequest(
101
101
  `[mq-client-core/scheduler-request] ${source} does not hold: ${describeErrors(validateDeclaration.errors)} - `
102
102
  + 'Expected: every record carries id, cookbook_ref, cron (five fields) and an explicit '
103
103
  + 'IANA timezone, and nothing this shape does not declare. '
@@ -110,7 +110,7 @@ function validateRecurrences(document, source) {
110
110
  const seen = new Set();
111
111
  for (const recurrence of document.recurrences) {
112
112
  if (seen.has(recurrence.id)) {
113
- throw new Error(
113
+ throw invalidRequest(
114
114
  `[mq-client-core/scheduler-request] ${source} declares the id "${recurrence.id}" twice - Expected: one record per `
115
115
  + 'id, because the id travels as the scheduler\'s idempotency_key and a second record '
116
116
  + 'would silently overwrite the first at every boot. '
@@ -123,6 +123,26 @@ function validateRecurrences(document, source) {
123
123
  return document.recurrences;
124
124
  }
125
125
 
126
+ /**
127
+ * The code every refusal of a builder carries — and only those (`SCHEDULER_REQUEST_INVALID`).
128
+ *
129
+ * A caller has two kinds of failure to tell apart and must never do it by the wording of a
130
+ * message: a request that cannot be built (a record that breaks the declaration rules, tenancy,
131
+ * a missing key or requester, a key the port does not declare) and a request that could not be
132
+ * SENT (no connection, a publish the broker refused). The wrapper's boot phase 0.7b isolates the
133
+ * first as a failed source and stops the boot on the second (confirmation `scheduler` 020,
134
+ * operational reading 1). So the builders mark their refusals with this code, and
135
+ * `publishSchedulerRequest()` and the port's construction never do.
136
+ */
137
+ const SCHEDULER_REQUEST_INVALID = 'SCHEDULER_REQUEST_INVALID';
138
+
139
+ /** A builder's refusal: the message as written, plus the code that names its kind. */
140
+ function invalidRequest(message) {
141
+ const error = new Error(message);
142
+ error.code = SCHEDULER_REQUEST_INVALID;
143
+ return error;
144
+ }
145
+
126
146
  /** A whole number a tenant or workspace id may be: ≥ 1, never 0 (confirmation scheduler 002). */
127
147
  const isContextId = (value) => Number.isInteger(value) && value >= 1;
128
148
 
@@ -169,7 +189,7 @@ const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
169
189
  /** The key that identifies the item, whatever kind it is - required by the contract's root. */
170
190
  function requireIdempotencyKey(idempotencyKey) {
171
191
  if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
172
- throw new Error(
192
+ throw invalidRequest(
173
193
  '[mq-client-core/scheduler-request] Missing idempotency_key - Expected: the stable identity '
174
194
  + 'of this item, chosen by the caller. The scheduler decides a one-shot item ONCE by this '
175
195
  + 'key, so a key invented here would turn every retry of one fact into a second run. '
@@ -183,7 +203,7 @@ function requireIdempotencyKey(idempotencyKey) {
183
203
  /** The service registering, cancelling or reporting - checked by the scheduler against services.json. */
184
204
  function requireRequestedBy(requestedBy) {
185
205
  if (typeof requestedBy !== 'string' || requestedBy.trim() === '') {
186
- throw new Error(
206
+ throw invalidRequest(
187
207
  '[mq-client-core/scheduler-request] Missing requested_by - Expected: the platform name of the '
188
208
  + 'service sending this request; the scheduler checks it against api/config/services.json '
189
209
  + "and against the cookbook's own trigger declaration. "
@@ -197,7 +217,7 @@ function requireRequestedBy(requestedBy) {
197
217
  /** Both context ids, under the one rule confirmation `scheduler` 002 fixed. */
198
218
  function requireTenancy(tenantId, workspaceId) {
199
219
  if (!isContextId(tenantId)) {
200
- throw new Error(
220
+ throw invalidRequest(
201
221
  `[mq-client-core/scheduler-request] Invalid tenant_id ${JSON.stringify(tenantId)} - Expected: `
202
222
  + 'a whole number of at least 1. The owner rejected a reserved value: 0 is not a platform '
203
223
  + 'scope, and an absent tenant is an error rather than a default (confirmation scheduler 002). '
@@ -208,7 +228,7 @@ function requireTenancy(tenantId, workspaceId) {
208
228
  }
209
229
 
210
230
  if (!isContextId(workspaceId)) {
211
- throw new Error(
231
+ throw invalidRequest(
212
232
  `[mq-client-core/scheduler-request] Invalid workspace_id ${JSON.stringify(workspaceId)} - `
213
233
  + 'Expected: a whole number of at least 1, under the same rule as the tenant (confirmation '
214
234
  + 'scheduler 002). Fix: pass workspaceId with every request - a recurrence carries it in the '
@@ -242,7 +262,7 @@ function buildEventRequest({ event, tenantId, workspaceId, requestedBy, idempote
242
262
  requireTenancy(tenantId, workspaceId);
243
263
 
244
264
  if (!event || typeof event !== 'object' || Array.isArray(event)) {
245
- throw new Error(
265
+ throw invalidRequest(
246
266
  `[mq-client-core/scheduler-request] Missing event - Expected: { service, name } - who it `
247
267
  + 'happened to and what happened, both of them. '
248
268
  + 'Fix: pass the fact this service publishes, never a cookbook name.'
@@ -251,7 +271,7 @@ function buildEventRequest({ event, tenantId, workspaceId, requestedBy, idempote
251
271
 
252
272
  for (const [field, value] of [['service', event.service], ['name', event.name]]) {
253
273
  if (typeof value !== 'string' || !NAME_PATTERN.test(value)) {
254
- throw new Error(
274
+ throw invalidRequest(
255
275
  `[mq-client-core/scheduler-request] Invalid event.${field} ${JSON.stringify(value)} - `
256
276
  + 'Expected: the platform spelling, lower case letters, digits and dashes '
257
277
  + '(the pattern the scheduler\'s own contract declares). '
@@ -414,7 +434,7 @@ function createSchedulerPublisher({ mqClient, requestedBy } = {}) {
414
434
  // something, and dropping it in silence hides both (`architecture-principles.md` §3).
415
435
  for (const key of Object.keys(record)) {
416
436
  if (!RECURRING_PORT_KEYS.includes(key)) {
417
- throw new Error(
437
+ throw invalidRequest(
418
438
  `[mq-client-core/scheduler-request] registerRecurring got a key the request does not declare ("${key}") - `
419
439
  + 'Expected: exactly { id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }. '
420
440
  + 'Fix: remove the key from the record the service returns / the ctx.scheduler call.'
@@ -472,6 +492,7 @@ function createSchedulerPublisher({ mqClient, requestedBy } = {}) {
472
492
  module.exports = {
473
493
  schedulerRequestQueue,
474
494
  SENDABLE_KINDS,
495
+ SCHEDULER_REQUEST_INVALID,
475
496
  validateRecurrences,
476
497
  buildRecurringRequest,
477
498
  buildEventRequest,