@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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* deliveryPolicy.js — the dead-letter policy of `consume()`, declared in one place.
|
|
5
|
+
*
|
|
6
|
+
* The policy is the SINGLE rail for every consumer, business and infrastructure
|
|
7
|
+
* alike (`api/docs/governance/confirmations/mq-consumer-contract.md` 001 and 002
|
|
8
|
+
* point 3). It answers one question: a handler threw — what happens to the
|
|
9
|
+
* message?
|
|
10
|
+
*
|
|
11
|
+
* transient error, attempts left → one more attempt
|
|
12
|
+
* transient error, budget spent → rejected into `<svc>.dlq`
|
|
13
|
+
* permanent error → rejected into `<svc>.dlq` on the first failure
|
|
14
|
+
*
|
|
15
|
+
* ## Why the counter is a header we write, and why an attempt costs a re-publish
|
|
16
|
+
*
|
|
17
|
+
* Measured on the live broker (`api_services_queuer`, 2026-09-11):
|
|
18
|
+
*
|
|
19
|
+
* | move | what the next delivery carries |
|
|
20
|
+
* |---|---|
|
|
21
|
+
* | `nack(requeue=true)` | `headers: {}` — nothing. `fields.redelivered` flips to `true`, which is a boolean, not a count. A requeue is NOT a dead-letter, so the broker stamps no `x-death`. |
|
|
22
|
+
* | `nack(requeue=false)` | the broker dead-letters and stamps `x-death[0] = { count: 1, reason: 'rejected', queue: <origin>, … }` |
|
|
23
|
+
*
|
|
24
|
+
* So `x-death` cannot count attempts: it only ever appears once the message has
|
|
25
|
+
* already left the queue for good. And a requeue returns the message
|
|
26
|
+
* byte-for-byte, headers included, so a requeued message cannot carry a counter
|
|
27
|
+
* either. The only way to advance one is to publish a NEW copy carrying it —
|
|
28
|
+
* publish the copy first, ack the original second, so a failed publish can never
|
|
29
|
+
* lose the message.
|
|
30
|
+
*
|
|
31
|
+
* ## The header name
|
|
32
|
+
*
|
|
33
|
+
* `x-oa-delivery-attempts`, not the `x-retry-count` of the retired
|
|
34
|
+
* `conn-infra-mq` retry layer: that name counted retries (0-based) on a rail
|
|
35
|
+
* nothing ever called, and reusing it would make two different countings share
|
|
36
|
+
* one name. `x-` is the AMQP custom-header space; `oa` says who writes it;
|
|
37
|
+
* `delivery-attempts` says which attempts are counted — this package also
|
|
38
|
+
* retries PUBLISHES (`layers/PublishLayer.js`), and a bare `x-oa-attempts`
|
|
39
|
+
* could not tell the two apart.
|
|
40
|
+
*
|
|
41
|
+
* ## What a caller may ask for, and where it is refused
|
|
42
|
+
*
|
|
43
|
+
* `maxAttempts` is the number of times the handler may run for ONE message —
|
|
44
|
+
* the same reading as `maxRetries` in `@onlineapps/error-handler-core`
|
|
45
|
+
* (`src/RetryHandler.js`, "Max attempts; integer >= 1"). After the
|
|
46
|
+
* `maxAttempts`-th failure the message is rejected into the dead-letter queue,
|
|
47
|
+
* so `maxAttempts: 1` means "no second attempt".
|
|
48
|
+
*
|
|
49
|
+
* An explicit value is validated exactly as written — never coerced, so a `'3'`
|
|
50
|
+
* or a `2.5` is refused instead of being silently turned into 3 or 2. Only the
|
|
51
|
+
* environment form goes through numeric coercion, because an environment
|
|
52
|
+
* variable has no other shape (`../config.js`, `../defaults.js`).
|
|
53
|
+
*
|
|
54
|
+
* Both refusals are raised at the throw site itself
|
|
55
|
+
* (`transports/rabbitmqClient.js` `consume()`), not here: a message this library
|
|
56
|
+
* hands a caller must open with the context that produced it
|
|
57
|
+
* (`architecture-principles.md` §5), and a context passed in as a parameter is
|
|
58
|
+
* invisible to the guard that checks that
|
|
59
|
+
* (`tests/unit/error-message-contract.test.js`).
|
|
60
|
+
*
|
|
61
|
+
* @see ../../README.md § consume() — the delivery contract
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/** Header carrying how many attempts a message has already cost. Never a literal elsewhere. */
|
|
65
|
+
const ATTEMPTS_HEADER = 'x-oa-delivery-attempts';
|
|
66
|
+
|
|
67
|
+
/** The error is worth another attempt. */
|
|
68
|
+
const TRANSIENT = 'transient';
|
|
69
|
+
|
|
70
|
+
/** The error will fail identically on every attempt — more attempts only delay the dead-letter. */
|
|
71
|
+
const PERMANENT = 'permanent';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Not a value a classifier may return: the classification recorded when the
|
|
75
|
+
* caller's classifier threw or answered with something that is neither
|
|
76
|
+
* `transient` nor `permanent`, and when the attempts header holds a value this
|
|
77
|
+
* library did not write. In both cases the message CANNOT be bounded, so it is
|
|
78
|
+
* rejected into the dead-letter queue rather than retried on a guess.
|
|
79
|
+
*/
|
|
80
|
+
const UNCLASSIFIABLE = 'unclassifiable';
|
|
81
|
+
|
|
82
|
+
const CLASSIFICATIONS = Object.freeze([TRANSIENT, PERMANENT]);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The classification used when the caller injects no classifier.
|
|
86
|
+
*
|
|
87
|
+
* Declared here, as a named module-owned decision with one owner, rather than
|
|
88
|
+
* as an inline `||` in the consume path — the shape `architecture-principles.md`
|
|
89
|
+
* §3 bans. What it means is stated, not implied: **without a classifier every
|
|
90
|
+
* error is treated as worth another attempt**, so a permanently failing message
|
|
91
|
+
* still reaches `<svc>.dlq`, after `maxAttempts` attempts instead of after one.
|
|
92
|
+
* The outcome is therefore bounded either way; a classifier only makes it
|
|
93
|
+
* faster and cheaper.
|
|
94
|
+
*
|
|
95
|
+
* The alternative — making `classify` required — was rejected on evidence: the
|
|
96
|
+
* eleven consumer paths measured for d.198 have no classifier of their own, so
|
|
97
|
+
* a required parameter would be satisfied at eleven call sites by copies of
|
|
98
|
+
* this very function (`change-discipline.md` § One rail per concern).
|
|
99
|
+
*
|
|
100
|
+
* @returns {'transient'}
|
|
101
|
+
*/
|
|
102
|
+
function defaultClassification() {
|
|
103
|
+
return TRANSIENT;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* How many attempts this message has already cost, read from its header.
|
|
108
|
+
*
|
|
109
|
+
* @param {Object} [headers] - `msg.properties.headers`.
|
|
110
|
+
* @returns {number|null} The count, `0` when the header is absent (first
|
|
111
|
+
* delivery), and `null` when the header holds a value this library never
|
|
112
|
+
* wrote — which the caller must treat as "cannot be bounded", never as zero:
|
|
113
|
+
* a counter reset on a malformed value is a counter an attacker or a bug can
|
|
114
|
+
* disable, and the redelivery loop is then unbounded again.
|
|
115
|
+
*/
|
|
116
|
+
function readAttemptsMade(headers) {
|
|
117
|
+
if (!headers || headers[ATTEMPTS_HEADER] === undefined || headers[ATTEMPTS_HEADER] === null) {
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const value = headers[ATTEMPTS_HEADER];
|
|
122
|
+
|
|
123
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Apply the caller's classifier to one error, and say plainly when it cannot be
|
|
132
|
+
* trusted. The library never guesses a classification: an unusable answer is
|
|
133
|
+
* reported as `unclassifiable`, and an unclassifiable message is dead-lettered.
|
|
134
|
+
*
|
|
135
|
+
* @param {function(Error): string} classify
|
|
136
|
+
* @param {Error} error
|
|
137
|
+
* @returns {{classification: string, failure: (Error|string|null)}} `failure` names
|
|
138
|
+
* why the classifier's answer was refused, for the caller to log; `null` when it held.
|
|
139
|
+
*/
|
|
140
|
+
function classifyError(classify, error) {
|
|
141
|
+
let answer;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
answer = classify(error);
|
|
145
|
+
} catch (classifyErr) {
|
|
146
|
+
return { classification: UNCLASSIFIABLE, failure: classifyErr };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!CLASSIFICATIONS.includes(answer)) {
|
|
150
|
+
return { classification: UNCLASSIFIABLE, failure: `returned ${JSON.stringify(answer)}` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { classification: answer, failure: null };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
ATTEMPTS_HEADER,
|
|
158
|
+
TRANSIENT,
|
|
159
|
+
PERMANENT,
|
|
160
|
+
UNCLASSIFIABLE,
|
|
161
|
+
CLASSIFICATIONS,
|
|
162
|
+
defaultClassification,
|
|
163
|
+
readAttemptsMade,
|
|
164
|
+
classifyError,
|
|
165
|
+
};
|