@onlineapps/mq-client-core 2.0.1 → 3.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 +153 -0
- package/README.md +491 -8
- package/package.json +12 -8
- package/src/BaseClient.js +953 -80
- package/src/buffer/InMemoryBuffer.js +50 -9
- package/src/buffer/MessageBuffer.js +20 -52
- package/src/config/composeConfig.js +60 -0
- package/src/config/configSchema.js +398 -17
- package/src/config/defaultConfig.js +169 -15
- package/src/config/deliveryPolicy.js +165 -0
- package/src/config/queueConfig.js +738 -64
- package/src/config.js +29 -0
- package/src/defaults.js +43 -0
- package/src/index.js +91 -2
- package/src/layers/PublishLayer.js +83 -37
- package/src/monitoring/PublishMonitor.js +11 -4
- package/src/monitoring-publish.js +81 -54
- package/src/transports/rabbitmqClient.js +2698 -738
- package/src/transports/transportFactory.js +9 -2
- package/src/utils/errorHandler.js +83 -4
- package/src/utils/nearestKey.js +101 -0
- package/src/utils/publishErrors.js +95 -10
- package/src/utils/redactCredentials.js +106 -0
- package/src/utils/serializer.js +12 -2
- package/src/workers/RecoveryWorker.js +58 -81
- package/src/buffer/RedisBuffer.js +0 -57
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const RabbitMQClient = require('./rabbitmqClient');
|
|
9
|
+
const { ValidationError } = require('../utils/errorHandler');
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Factory method: returns a transport instance based on config.type.
|
|
@@ -16,7 +17,10 @@ const RabbitMQClient = require('./rabbitmqClient');
|
|
|
16
17
|
*/
|
|
17
18
|
function create(config) {
|
|
18
19
|
if (!config || !config.type) {
|
|
19
|
-
throw new
|
|
20
|
+
throw new ValidationError(
|
|
21
|
+
'[transportFactory] Transport type is required in configuration - Expected: config.type naming a supported transport. '
|
|
22
|
+
+ "Fix: pass { type: 'rabbitmq', … } to the client constructor."
|
|
23
|
+
);
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
switch (config.type.toLowerCase()) {
|
|
@@ -24,7 +28,10 @@ function create(config) {
|
|
|
24
28
|
return new RabbitMQClient(config);
|
|
25
29
|
|
|
26
30
|
default:
|
|
27
|
-
throw new
|
|
31
|
+
throw new ValidationError(
|
|
32
|
+
`[transportFactory] Unsupported transport type: ${config.type} - Expected: one of the implemented transports ('rabbitmq'). `
|
|
33
|
+
+ "Fix: set config.type to 'rabbitmq'."
|
|
34
|
+
);
|
|
28
35
|
}
|
|
29
36
|
}
|
|
30
37
|
|
|
@@ -31,20 +31,61 @@ class ValidationError extends Error {
|
|
|
31
31
|
* Thrown when client cannot connect to broker.
|
|
32
32
|
* - message: description of what went wrong
|
|
33
33
|
* - cause: original error object
|
|
34
|
+
* - code: machine-readable classification, for the receivers that must decide what an
|
|
35
|
+
* error MEANS. A decision taken on the message text breaks the moment somebody
|
|
36
|
+
* rewords the sentence, and nothing reports it (`architecture-principles.md` §3).
|
|
34
37
|
*/
|
|
35
38
|
class ConnectionError extends Error {
|
|
36
39
|
/**
|
|
37
40
|
* @param {string} message
|
|
38
|
-
* @param {Error} cause
|
|
41
|
+
* @param {Error} [cause]
|
|
42
|
+
* @param {string} [code] one of the codes below
|
|
39
43
|
*/
|
|
40
|
-
constructor(message, cause) {
|
|
44
|
+
constructor(message, cause, code) {
|
|
41
45
|
super(message);
|
|
42
46
|
this.name = 'ConnectionError';
|
|
43
47
|
this.cause = cause;
|
|
48
|
+
this.code = code;
|
|
44
49
|
Error.captureStackTrace(this, ConnectionError);
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The broker connection dropped while the client still wanted it.
|
|
55
|
+
*
|
|
56
|
+
* It is a STATE, not the failure of whatever observed it: while a reconnect is in
|
|
57
|
+
* flight this is the normal condition of the very thing being waited for, so
|
|
58
|
+
* `RabbitMQClient._waitForReconnection()` skips it and keeps waiting. The wait also
|
|
59
|
+
* skips anything carrying a publish classification (`utils/publishErrors.js` sets
|
|
60
|
+
* `retryable` on every error it produces, retryable or not): that reports somebody
|
|
61
|
+
* else's message, not this connection (d.435, widened to the whole rail in d.435b).
|
|
62
|
+
* Everything else — an error with no classification at all — ends that wait.
|
|
63
|
+
*/
|
|
64
|
+
const CONNECTION_CLOSED_UNEXPECTEDLY = 'CONNECTION_CLOSED_UNEXPECTEDLY';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The single definition of that error — both connection close handlers emit exactly
|
|
68
|
+
* this, and the reconnect wait recognises it by `error.code`, never by its wording.
|
|
69
|
+
*
|
|
70
|
+
* Measured defect it closes (d.153a → d.164): the handlers emitted
|
|
71
|
+
* `new Error('RabbitMQ connection closed unexpectedly')` while the wait asked
|
|
72
|
+
* `message.includes('Connection closed unexpectedly')` — a capital `C` apart, so the
|
|
73
|
+
* substring never matched and the branch that was written to ignore this error had
|
|
74
|
+
* never once run.
|
|
75
|
+
*
|
|
76
|
+
* @returns {ConnectionError}
|
|
77
|
+
*/
|
|
78
|
+
function connectionClosedUnexpectedly() {
|
|
79
|
+
return new ConnectionError(
|
|
80
|
+
'[RabbitMQClient] Connection closed unexpectedly - '
|
|
81
|
+
+ 'Expected: the broker connection to stay open until disconnect() closes it. '
|
|
82
|
+
+ 'Fix: check the broker is up and reachable; the client reconnects on its own until '
|
|
83
|
+
+ 'RABBITMQ_MAX_RECONNECT_ATTEMPTS is spent and then emits connection:fatal.',
|
|
84
|
+
undefined,
|
|
85
|
+
CONNECTION_CLOSED_UNEXPECTEDLY
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
48
89
|
/**
|
|
49
90
|
* PublishError
|
|
50
91
|
* Thrown when publishing a message fails.
|
|
@@ -73,22 +114,56 @@ class PublishError extends Error {
|
|
|
73
114
|
* - message: description
|
|
74
115
|
* - queue: queue name associated with the consumer
|
|
75
116
|
* - cause: original error
|
|
117
|
+
* - code: machine-readable class of the refusal, for the caller that must decide
|
|
118
|
+
* what to DO about it. Absent when the failure belongs to no named class —
|
|
119
|
+
* never a catch-all value, because "I do not know which of these it is" and
|
|
120
|
+
* "it is this one" are different answers.
|
|
76
121
|
*/
|
|
77
122
|
class ConsumeError extends Error {
|
|
78
123
|
/**
|
|
79
124
|
* @param {string} message
|
|
80
125
|
* @param {string} queue
|
|
81
|
-
* @param {Error} cause
|
|
126
|
+
* @param {Error} [cause]
|
|
127
|
+
* @param {string} [code] one of the consumer codes below
|
|
82
128
|
*/
|
|
83
|
-
constructor(message, queue, cause) {
|
|
129
|
+
constructor(message, queue, cause, code) {
|
|
84
130
|
super(message);
|
|
85
131
|
this.name = 'ConsumeError';
|
|
86
132
|
this.queue = queue;
|
|
87
133
|
this.cause = cause;
|
|
134
|
+
if (code !== undefined) this.code = code;
|
|
88
135
|
Error.captureStackTrace(this, ConsumeError);
|
|
89
136
|
}
|
|
90
137
|
}
|
|
91
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The two classes of refusal `consume()` makes before a consumer is attached,
|
|
141
|
+
* as codes rather than as sentences.
|
|
142
|
+
*
|
|
143
|
+
* They exist because those two refusals ask the reader for OPPOSITE actions, and
|
|
144
|
+
* until 2026-09-14 they arrived wearing the same sentence: `BaseClient.consume()`
|
|
145
|
+
* rewrote every failure as "the queue to exist before consume() attaches to it",
|
|
146
|
+
* which for a queue with no declared dead-letter route is false in every clause —
|
|
147
|
+
* the queue exists, creating it changes nothing, and its owning service is not
|
|
148
|
+
* who has to act. The true sentence survived only in `error.cause`, where a log
|
|
149
|
+
* line does not look.
|
|
150
|
+
*
|
|
151
|
+
* A caller decides on the CODE. Deciding on the wording breaks the moment somebody
|
|
152
|
+
* rewords the sentence, and nothing reports the break (`architecture-principles.md`
|
|
153
|
+
* §3) — the defect d.164 measured, where a reconnect wait compared strings and its
|
|
154
|
+
* branch had never once run.
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
/** The queue does not exist. Its owner creates it; the consumer never does. */
|
|
158
|
+
const CONSUMER_QUEUE_MISSING = 'CONSUMER_QUEUE_MISSING';
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The queue exists, and `queueConfig` declares no dead-letter route for it, so
|
|
162
|
+
* the delivery policy has nowhere to reject a spent message to (d.259). The fix
|
|
163
|
+
* is a declaration, not a queue.
|
|
164
|
+
*/
|
|
165
|
+
const CONSUMER_DEAD_LETTER_ROUTE_MISSING = 'CONSUMER_DEAD_LETTER_ROUTE_MISSING';
|
|
166
|
+
|
|
92
167
|
/**
|
|
93
168
|
* SerializationError
|
|
94
169
|
* Thrown when JSON serialization or deserialization fails.
|
|
@@ -114,8 +189,12 @@ class SerializationError extends Error {
|
|
|
114
189
|
module.exports = {
|
|
115
190
|
ValidationError,
|
|
116
191
|
ConnectionError,
|
|
192
|
+
CONNECTION_CLOSED_UNEXPECTEDLY,
|
|
193
|
+
connectionClosedUnexpectedly,
|
|
117
194
|
PublishError,
|
|
118
195
|
ConsumeError,
|
|
196
|
+
CONSUMER_QUEUE_MISSING,
|
|
197
|
+
CONSUMER_DEAD_LETTER_ROUTE_MISSING,
|
|
119
198
|
SerializationError,
|
|
120
199
|
};
|
|
121
200
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Did you mean this one" — the one implementation of it in this package.
|
|
5
|
+
*
|
|
6
|
+
* It lived inside `BaseClient.js` from d.291b, where the config schema first
|
|
7
|
+
* refused an undeclared key by name. d.396c gave `assertQueue()` the same duty
|
|
8
|
+
* for its own option set, and a second copy of an edit distance would be a
|
|
9
|
+
* second rail for one concern (`change-discipline.md` § One rail per concern) —
|
|
10
|
+
* so the pair moved here and both callers require it. The behaviour is
|
|
11
|
+
* unchanged; the tests that pinned it (`tests/unit/config-schema-refuses-unknown-key.test.js`)
|
|
12
|
+
* still read it through `BaseClient`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Edit distance between two names, compared case-insensitively.
|
|
17
|
+
*
|
|
18
|
+
* The classic Levenshtein recurrence over one rolling row. It exists for ONE
|
|
19
|
+
* purpose — turning "this key is not declared" into "did you mean this one" — so
|
|
20
|
+
* it is deliberately the plainest metric there is: same inputs, same answer, no
|
|
21
|
+
* dictionary, no heuristics to explain (`automation-gates.md` §1 requirement 1).
|
|
22
|
+
*
|
|
23
|
+
* @param {string} a
|
|
24
|
+
* @param {string} b
|
|
25
|
+
* @returns {number} insertions + deletions + substitutions between the two.
|
|
26
|
+
*/
|
|
27
|
+
function editDistance(a, b) {
|
|
28
|
+
const from = a.toLowerCase();
|
|
29
|
+
const to = b.toLowerCase();
|
|
30
|
+
let previous = Array.from({ length: to.length + 1 }, (_, j) => j);
|
|
31
|
+
|
|
32
|
+
for (let i = 1; i <= from.length; i += 1) {
|
|
33
|
+
const row = [i];
|
|
34
|
+
for (let j = 1; j <= to.length; j += 1) {
|
|
35
|
+
row[j] = Math.min(
|
|
36
|
+
previous[j] + 1,
|
|
37
|
+
row[j - 1] + 1,
|
|
38
|
+
previous[j - 1] + (from[i - 1] === to[j - 1] ? 0 : 1)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
previous = row;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return previous[to.length];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The declared key a written name is closest to, or `null` when nothing is close
|
|
49
|
+
* enough to name.
|
|
50
|
+
*
|
|
51
|
+
* The budget is a third of the longer of the two names, rounded up: `prefech` ->
|
|
52
|
+
* `prefetch` (distance 1) is a typo worth pointing at, `queue` -> `type`
|
|
53
|
+
* (distance 4 over a 5-character name) is not, and a guess that far off steers a
|
|
54
|
+
* reader away from the real answer, which is that the key is not a key at all.
|
|
55
|
+
* Ties keep the FIRST declared key, so the sentence does not change with the
|
|
56
|
+
* iteration order of an object (`automation-gates.md` §1 requirement 1).
|
|
57
|
+
*
|
|
58
|
+
* @param {string} written - The key the caller wrote.
|
|
59
|
+
* @param {string[]} declaredKeys - Every key the schema declares, subclass keys included.
|
|
60
|
+
* @returns {string|null}
|
|
61
|
+
*/
|
|
62
|
+
function nearestDeclaredKey(written, declaredKeys) {
|
|
63
|
+
let nearest = null;
|
|
64
|
+
let nearestDistance = Infinity;
|
|
65
|
+
|
|
66
|
+
for (const candidate of declaredKeys) {
|
|
67
|
+
const distance = editDistance(written, candidate);
|
|
68
|
+
if (distance < nearestDistance) {
|
|
69
|
+
nearestDistance = distance;
|
|
70
|
+
nearest = candidate;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (nearest === null) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const budget = Math.ceil(Math.max(written.length, nearest.length) / 3);
|
|
79
|
+
return nearestDistance <= budget ? nearest : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The undeclared keys of an object, each named together with the declared key it
|
|
84
|
+
* is closest to — the half of the refusal sentence both callers share.
|
|
85
|
+
*
|
|
86
|
+
* @param {string[]} unknownKeys - Keys the declared set does not contain.
|
|
87
|
+
* @param {string[]} declaredKeys - The declared set.
|
|
88
|
+
* @returns {string} e.g. `"persistent" (no declared key is close to it)`
|
|
89
|
+
*/
|
|
90
|
+
function nameUnknownKeys(unknownKeys, declaredKeys) {
|
|
91
|
+
return unknownKeys
|
|
92
|
+
.map((key) => {
|
|
93
|
+
const nearest = nearestDeclaredKey(key, declaredKeys);
|
|
94
|
+
return nearest === null
|
|
95
|
+
? `"${key}" (no declared key is close to it)`
|
|
96
|
+
: `"${key}" (the closest declared key is "${nearest}")`;
|
|
97
|
+
})
|
|
98
|
+
.join(', ');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { editDistance, nearestDeclaredKey, nameUnknownKeys };
|
|
@@ -36,8 +36,8 @@ class QueueNotFoundError extends PermanentPublishError {
|
|
|
36
36
|
* @param {string} queueName
|
|
37
37
|
* @param {boolean} isInfrastructure
|
|
38
38
|
* @param {Error} [cause]
|
|
39
|
-
* @param {('infrastructure'|'business'|'unknown')} [kind] - Which ownership
|
|
40
|
-
* was violated. Defaults to `'infrastructure'`/`'unknown'` from
|
|
39
|
+
* @param {('infrastructure'|'business'|'unowned'|'unknown')} [kind] - Which ownership
|
|
40
|
+
* rule was violated. Defaults to `'infrastructure'`/`'unknown'` from
|
|
41
41
|
* `isInfrastructure`, so existing two- and three-argument callers are unchanged.
|
|
42
42
|
*/
|
|
43
43
|
constructor(queueName, isInfrastructure, cause, kind) {
|
|
@@ -45,19 +45,42 @@ class QueueNotFoundError extends PermanentPublishError {
|
|
|
45
45
|
let baseMessage;
|
|
46
46
|
if (resolvedKind === 'infrastructure') {
|
|
47
47
|
baseMessage =
|
|
48
|
-
`Cannot publish to infrastructure queue ${queueName}: queue does not exist. ` +
|
|
49
|
-
'
|
|
50
|
-
'(TTL, max-length,
|
|
48
|
+
`[RabbitMQClient] Cannot publish to infrastructure queue ${queueName}: queue does not exist. ` +
|
|
49
|
+
'Expected: infrastructure queues are created explicitly, with the arguments queueConfig ' +
|
|
50
|
+
'prescribes (TTL, max-length, …), before anything publishes to them. ' +
|
|
51
|
+
'Fix: start the infrastructure service that owns the queue — it calls ' +
|
|
52
|
+
'initInfrastructureQueues() from @onlineapps/infrastructure-tools — and publish afterwards.';
|
|
51
53
|
} else if (resolvedKind === 'business') {
|
|
52
54
|
baseMessage =
|
|
53
|
-
`Cannot publish to business queue ${queueName}: queue does not exist. ` +
|
|
55
|
+
`[RabbitMQClient] Cannot publish to business queue ${queueName}: queue does not exist. ` +
|
|
54
56
|
'Expected: business queues are created by their owning service via ' +
|
|
55
57
|
'setupServiceQueues() AFTER successful registration, with the arguments ' +
|
|
56
58
|
'queueConfig prescribes (TTL, DLQ). Fix: call setupServiceQueues() before ' +
|
|
57
59
|
'publishing — publishing must never create the queue, because sendToQueue() ' +
|
|
58
60
|
'would create it with default arguments (no TTL, no DLQ).';
|
|
61
|
+
} else if (resolvedKind === 'unowned') {
|
|
62
|
+
// A name the central config classifies as NEITHER infrastructure nor
|
|
63
|
+
// business. Nothing declares it, so nothing may bring it into being: a
|
|
64
|
+
// queue created here would carry no TTL and no dead-letter route, and
|
|
65
|
+
// `consume()` refuses a queue with no declared route (d.259) — publishable
|
|
66
|
+
// and unconsumable, which is not a queue anybody can use. The owner's
|
|
67
|
+
// decision says how a genuinely new family of queues arrives instead:
|
|
68
|
+
// a template in queueConfig first, "never a generic create-anything
|
|
69
|
+
// method" (`docs/governance/confirmations/mq-consumer-contract.md` 006).
|
|
70
|
+
baseMessage =
|
|
71
|
+
`[RabbitMQClient] Queue ${queueName} has no owner in queueConfig - ` +
|
|
72
|
+
'Expected: every queue to come into being from a declaration — an infrastructure queue from ' +
|
|
73
|
+
'the service that owns it at boot, a business queue from setupServiceQueues() over the ' +
|
|
74
|
+
'queueConfig template. A publisher declares neither, and this name matches neither. ' +
|
|
75
|
+
'Fix: add a template for this name to src/config/queueConfig.js (confirmation ' +
|
|
76
|
+
'mq-consumer-contract 003) and let its owner declare it, or publish to a queue that ' +
|
|
77
|
+
'already has one.';
|
|
59
78
|
} else {
|
|
60
|
-
baseMessage =
|
|
79
|
+
baseMessage =
|
|
80
|
+
`[RabbitMQClient] Queue ${queueName} does not exist - Expected: the queue to have been created by whoever owns it. ` +
|
|
81
|
+
'Fix: the owner is not known at this point, so identify it from the queue name — an infrastructure prefix ' +
|
|
82
|
+
'(workflow./registry./infrastructure./monitoring.) means an infrastructure service, anything else means the ' +
|
|
83
|
+
'business service the first name segment belongs to.';
|
|
61
84
|
}
|
|
62
85
|
super(baseMessage, cause);
|
|
63
86
|
this.name = 'QueueNotFoundError';
|
|
@@ -93,14 +116,72 @@ function missingInfrastructureQueueMessage(queueName) {
|
|
|
93
116
|
+ '@onlineapps/infrastructure-tools.';
|
|
94
117
|
}
|
|
95
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The message a CONSUMER gets when the central config holds no DEFINITION for the
|
|
121
|
+
* queue it must attach to — a configuration defect, never a runtime condition.
|
|
122
|
+
*
|
|
123
|
+
* ONE producer for both classes of name. The sentence differs in exactly two
|
|
124
|
+
* places — which part of `queueConfig` must declare the name, and what a reader
|
|
125
|
+
* adds there — and writing that difference as a ternary at the `new ConsumeError`
|
|
126
|
+
* call site puts the whole message where no test can reach it
|
|
127
|
+
* (`tests/unit/error-message-contract.test.js`: a non-literal argument must be a
|
|
128
|
+
* named `…Message()` producer, checked at runtime beside its siblings).
|
|
129
|
+
*
|
|
130
|
+
* @param {string} queueName - The queue whose definition is missing.
|
|
131
|
+
* @param {boolean} isInfrastructure - Whether the name is an infrastructure one.
|
|
132
|
+
* Required, and required to be a boolean: a missing third state would be
|
|
133
|
+
* rendered as "business" by a falsy value, which is the wrong instruction.
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
function missingQueueDefinitionMessage(queueName, isInfrastructure) {
|
|
137
|
+
if (!queueName) {
|
|
138
|
+
throw new Error('[publishErrors] queueName is required - Expected the name of the queue whose definition is missing');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (typeof isInfrastructure !== 'boolean') {
|
|
142
|
+
throw new Error(
|
|
143
|
+
'[publishErrors] isInfrastructure is required - Expected: a boolean saying which part of queueConfig '
|
|
144
|
+
+ 'must declare the name. Fix: pass queueConfig.isInfrastructureQueue(queueName).'
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return isInfrastructure
|
|
149
|
+
? `[RabbitMQClient] No queue definition for infrastructure queue "${queueName}" - `
|
|
150
|
+
+ 'Expected: queueConfig defines every infrastructure queue that is consumed. '
|
|
151
|
+
+ 'Fix: add the queue to src/config/queueConfig.js, or consume the name the owning service declares.'
|
|
152
|
+
: `[RabbitMQClient] No queue definition for business queue "${queueName}" - `
|
|
153
|
+
+ 'Expected: queueConfig defines a template for every business queue type (workflow, queue, dlq). '
|
|
154
|
+
+ 'Fix: add the template to src/config/queueConfig.js, or consume the name the owning service declares.';
|
|
155
|
+
}
|
|
156
|
+
|
|
96
157
|
/**
|
|
97
158
|
* Best-effort klasifikace chyb z publishu do specializovaných typů.
|
|
98
159
|
* Vrací původní chybu, pokud neodpovídá žádnému známému patternu.
|
|
99
160
|
*
|
|
161
|
+
* The queue name comes from the CALLER, and is required. The missing-queue branch
|
|
162
|
+
* below used to invent it — `new QueueNotFoundError('unknown', …)` — and the
|
|
163
|
+
* invented name does not stay in the message: the error reaches `RecoveryWorker`
|
|
164
|
+
* through the client's `error` channel, which reports it by `error.queueName`
|
|
165
|
+
* ("Cannot create queue 'unknown'"), naming nothing anybody can act on. Every
|
|
166
|
+
* caller has the real name in hand (`_publishOnce(queue, …)`,
|
|
167
|
+
* `publishToMonitoringResilient(…, queueName, …)`), so it is asked for at entry
|
|
168
|
+
* (`architecture-principles.md` §4) rather than guessed in one branch (§3, and the
|
|
169
|
+
* same defect this package retired for the workflow id in d.416).
|
|
170
|
+
*
|
|
100
171
|
* @param {Error} err
|
|
172
|
+
* @param {string} queueName - The queue whose publish failed.
|
|
101
173
|
* @returns {Error} - buď speciální error, nebo původní err
|
|
102
174
|
*/
|
|
103
|
-
function classifyPublishError(err) {
|
|
175
|
+
function classifyPublishError(err, queueName) {
|
|
176
|
+
if (typeof queueName !== 'string' || queueName === '') {
|
|
177
|
+
throw new Error(
|
|
178
|
+
'[publishErrors] classifyPublishError requires the queue name - '
|
|
179
|
+
+ 'Expected: the caller names the queue whose publish failed, so a missing-queue error '
|
|
180
|
+
+ 'carries a real name instead of an invented one. '
|
|
181
|
+
+ 'Fix: pass it as the second argument, e.g. classifyPublishError(err, queue).'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
104
185
|
if (!err) {
|
|
105
186
|
return err;
|
|
106
187
|
}
|
|
@@ -118,8 +199,11 @@ function classifyPublishError(err) {
|
|
|
118
199
|
|
|
119
200
|
// Queue neexistuje – trvalý problém, nerozbitný retry
|
|
120
201
|
if (message.includes('queue does not exist') || message.includes('no queue') || err.code === 404) {
|
|
121
|
-
//
|
|
122
|
-
|
|
202
|
+
// Which section of queueConfig owns the name is not known here — that lookup
|
|
203
|
+
// belongs to the publish path, which asks it before this classification is ever
|
|
204
|
+
// reached — so the error carries the name and `kind: 'unknown'`, whose message
|
|
205
|
+
// tells the reader how to identify the owner from it.
|
|
206
|
+
return new QueueNotFoundError(queueName, false, err);
|
|
123
207
|
}
|
|
124
208
|
|
|
125
209
|
// Typicky transientní problémy connection/channel
|
|
@@ -143,6 +227,7 @@ function classifyPublishError(err) {
|
|
|
143
227
|
|
|
144
228
|
module.exports = {
|
|
145
229
|
missingInfrastructureQueueMessage,
|
|
230
|
+
missingQueueDefinitionMessage,
|
|
146
231
|
TransientPublishError,
|
|
147
232
|
PermanentPublishError,
|
|
148
233
|
QueueNotFoundError,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* redactCredentials.js
|
|
5
|
+
*
|
|
6
|
+
* ONE rail for making a broker connection target safe to log. Both connect
|
|
7
|
+
* paths (BaseClient and the RabbitMQ transport) pass their target through this
|
|
8
|
+
* function before it reaches stdout; nothing else in this package renders a
|
|
9
|
+
* host, a URL or a config object into a log line.
|
|
10
|
+
*
|
|
11
|
+
* Why it exists: the full AMQP URL — password included — was printed on every
|
|
12
|
+
* boot, so the broker account was readable in container stdout and, through the
|
|
13
|
+
* monitoring consumer, in Loki (measured 2026-09-07 on api_service_hello).
|
|
14
|
+
*
|
|
15
|
+
* `redactUrl` returns the URL with the WHOLE userinfo removed: what stays is
|
|
16
|
+
* scheme, host, port, vhost and query — the part a reader of the log actually
|
|
17
|
+
* came for. The account NAME goes with the password, because it is the other
|
|
18
|
+
* half of the same credential, and because this is the redaction semantics the
|
|
19
|
+
* platform already writes: `shared/service-common/src/redactUrl.js`
|
|
20
|
+
* (d.245/d.245b, Redis) and `shared/connector/conn-orch-registry/src/redactUrl.js`
|
|
21
|
+
* (d.446, AMQP). Until d.448 this module masked the password only, which made
|
|
22
|
+
* two semantics for one concern — two rails (`change-discipline.md` § One rail
|
|
23
|
+
* per concern). `src/index.js` exports the function so the dependants import it
|
|
24
|
+
* instead of keeping a local copy.
|
|
25
|
+
*
|
|
26
|
+
* A value that is not a parseable URL is answered with a fixed placeholder
|
|
27
|
+
* rather than echoed: an unparseable string is exactly the case where nobody can
|
|
28
|
+
* say whether it holds a credential, and echoing it would be the leak this
|
|
29
|
+
* helper exists to prevent.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const UNPARSEABLE_PLACEHOLDER = '<unparseable-url>';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Strip userinfo (`user:password@`) from a connection URL.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} url - connection URL, e.g. `amqp://oa_dev:secret@queuer:5672/vhost`
|
|
38
|
+
* @returns {string} the URL without userinfo, e.g. `amqp://queuer:5672/vhost`,
|
|
39
|
+
* or `<unparseable-url>` when the input is not a URL.
|
|
40
|
+
*/
|
|
41
|
+
function redactUrl(url) {
|
|
42
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
43
|
+
return UNPARSEABLE_PLACEHOLDER;
|
|
44
|
+
}
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = new URL(url);
|
|
48
|
+
} catch (_) {
|
|
49
|
+
return UNPARSEABLE_PLACEHOLDER;
|
|
50
|
+
}
|
|
51
|
+
// `new URL('api_services_queuer:5672')` SUCCEEDS — it reads `api_services_queuer:`
|
|
52
|
+
// as the scheme and `5672` as an opaque path, so the value comes back with an
|
|
53
|
+
// empty host and no userinfo to strip. Without this guard such a value would be
|
|
54
|
+
// echoed verbatim, which is the leak this helper exists to prevent.
|
|
55
|
+
if (!parsed.host) {
|
|
56
|
+
return UNPARSEABLE_PLACEHOLDER;
|
|
57
|
+
}
|
|
58
|
+
parsed.username = '';
|
|
59
|
+
parsed.password = '';
|
|
60
|
+
return parsed.href;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Makes a connection target safe to log.
|
|
65
|
+
*
|
|
66
|
+
* ONE shape, because this client has one: `host` is a non-empty URL STRING,
|
|
67
|
+
* declared so by `config/configSchema.js`, resolved from `RABBITMQ_URL` when the
|
|
68
|
+
* caller omits it, and refused by the transport constructor when it is anything
|
|
69
|
+
* else (d.292 §2/§3). Until d.311 this module also carried `redactOptions()` for
|
|
70
|
+
* amqplib's options-object target — a branch no input could reach any more, kept
|
|
71
|
+
* alive only by its own tests. The four questions of `change-discipline.md`
|
|
72
|
+
* § Removing something removes its declaration:
|
|
73
|
+
*
|
|
74
|
+
* 1. it arrived with the redactor, when amqplib's two target shapes were both
|
|
75
|
+
* accepted here;
|
|
76
|
+
* 2. the concept that carried it — "a connection target must be safe to log,
|
|
77
|
+
* whatever shape it has" — was narrowed by d.292 to one shape, on purpose:
|
|
78
|
+
* the second one silently discarded the caller's value;
|
|
79
|
+
* 3. nothing reads it because that shape cannot arrive, not because anybody
|
|
80
|
+
* stopped caring;
|
|
81
|
+
* 4. what stands in its place is more conceptual — one declared shape, refused
|
|
82
|
+
* loudly if it is anything else — so the answer is "delete", and a branch
|
|
83
|
+
* that can never run is the silence `automation-gates.md` §5 calls a defect.
|
|
84
|
+
*
|
|
85
|
+
* It throws where `redactUrl` returns a placeholder, and that difference is
|
|
86
|
+
* deliberate: this function guards a DECLARED config key, where a wrong type is
|
|
87
|
+
* a boot-time defect the service must die on (§4 fail-fast), while `redactUrl`
|
|
88
|
+
* serves log lines and error messages, where a throw would replace the report
|
|
89
|
+
* with a second incident.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} target - AMQP URL string.
|
|
92
|
+
* @returns {string} The same URL without its userinfo.
|
|
93
|
+
* @throws {TypeError} If the target is not a string. The message names the key to
|
|
94
|
+
* write and NEVER the rejected value, which carries the broker password.
|
|
95
|
+
*/
|
|
96
|
+
function redactConnectionTarget(target) {
|
|
97
|
+
if (typeof target === 'string') return redactUrl(target);
|
|
98
|
+
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
`[redactCredentials] Unsupported connection target - expected an AMQP URL string, got ` +
|
|
101
|
+
`"${target === null ? 'null' : typeof target}". ` +
|
|
102
|
+
`Fix: set config.host to the broker URL.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { redactConnectionTarget, redactUrl, UNPARSEABLE_PLACEHOLDER };
|
package/src/utils/serializer.js
CHANGED
|
@@ -19,7 +19,12 @@ function serialize(obj) {
|
|
|
19
19
|
try {
|
|
20
20
|
return JSON.stringify(obj);
|
|
21
21
|
} catch (err) {
|
|
22
|
-
throw new SerializationError(
|
|
22
|
+
throw new SerializationError(
|
|
23
|
+
'[serializer] Failed to serialize object - Expected: a value JSON.stringify accepts. '
|
|
24
|
+
+ 'Fix: read error.cause for the reason (circular reference, BigInt, …) and error.payload for the value that was rejected.',
|
|
25
|
+
obj,
|
|
26
|
+
err
|
|
27
|
+
);
|
|
23
28
|
}
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -34,7 +39,12 @@ function deserialize(buffer) {
|
|
|
34
39
|
const str = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : buffer;
|
|
35
40
|
return JSON.parse(str);
|
|
36
41
|
} catch (err) {
|
|
37
|
-
throw new SerializationError(
|
|
42
|
+
throw new SerializationError(
|
|
43
|
+
'[serializer] Failed to deserialize payload - Expected: a Buffer or string holding valid JSON. '
|
|
44
|
+
+ 'Fix: read error.cause for the parse reason and error.payload for the bytes that were rejected.',
|
|
45
|
+
buffer,
|
|
46
|
+
err
|
|
47
|
+
);
|
|
38
48
|
}
|
|
39
49
|
}
|
|
40
50
|
|