@onlineapps/mq-client-core 3.0.1 → 3.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,71 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.1.0] — 2026-09-15
8
+
9
+ ### Added — `topology` nese i tři telemetrické fronty (d.461)
10
+
11
+ `module.exports.topology` vystavuje `telemetryLogsQueue`, `telemetryMetricsQueue`,
12
+ `telemetryTracesQueue`, složené ze sekce `telemetry` v `queueConfig` (tatáž
13
+ deklarace, ne kopie). Monitoring, jediný konzument těch front, může zrušit
14
+ svoje privátní literály a číst jména odtud; `src/config/queueConfig` zůstává
15
+ interní cestou. README § topology popisuje celý export.
16
+
17
+ ### Fixed — `performHealthCheck()` hlásí stav, nemění ho (d.467)
18
+
19
+ Kontrola spouštěla obnovovací cyklus bezpodmínečně: `_reconnectWithBackoff()`
20
+ nastaví `_reconnecting` synchronně, `isConnected()` ho čte, a tak zdravý klient
21
+ reportoval vlastní vedlejší účinek (`healthy: false`, `Connection is closed or
22
+ missing`, `isConnected()` true → false) a obnova skutečně vyměnila živý socket
23
+ za nový. Teď se obnova spouští jen u odstaveného klienta — otázku klade jednomu
24
+ zdroji živosti (`isConnected()` nad `_connectionAlive`), tam, kde ji kladou
25
+ `_ensurePublisherChannel()` a `_ensureConsumerChannel()`. Líný kontrakt d.339
26
+ zůstává: odstavený klient se health checkem dál vrací do provozu. Repro nad
27
+ živým brokerem s `reconnectEnabled: true` (starší sady o health checku stavěly
28
+ klienta s `reconnectEnabled: false`, proto vadu nikdy neviděly).
29
+
30
+ ### Fixed — `redactUrl` se bere z `@onlineapps/logger-contract`, lokální kopie pryč (d.455)
31
+
32
+ `src/utils/redactCredentials.js` držel vlastní `redactUrl`
33
+ a `UNPARSEABLE_PLACEHOLDER`. Od `@onlineapps/logger-contract` 1.2.0 (d.453) je
34
+ vlastní core vrstva, takže je tenhle modul importuje a `src/index.js` je
35
+ **re-exportuje identitou** — veřejné API se nemění, dependants dál píší
36
+ `require('@onlineapps/mq-client-core').redactUrl`. Zdejší zůstává jen
37
+ `redactConnectionTarget`: fail-fast obal nad deklarovaným config klíčem `host`,
38
+ který v core vrstvě nemá co dělat (vyhazuje tam, kde `redactUrl` vrací
39
+ placeholder — d.292 §2/§3).
40
+
41
+ Čtyři otázky ke smazané deklaraci (`change-discipline.md` § Removing):
42
+
43
+ 1. **Proč vznikla** — d.448 sjednotila sémantiku (celé userinfo pryč, ne jen
44
+ heslo) poté, co se na `api_service_hello` 2026-09-07 změřilo heslo brokeru
45
+ ve stdout a přes monitoring i v Loki. Core domov tehdy neexistoval, takže
46
+ funkce zůstala tady.
47
+ 2. **Která část koncepce ji nesla** — `change-discipline.md` § One rail per
48
+ concern ji naopak zakazuje: tři kopie jednoho pravidla (tady,
49
+ `service-common`, `conn-orch-registry`). Ta část koncepce stojí dál.
50
+ 3. **Proč ji dnes nikdo nečte** — `@onlineapps/logger-contract` 1.2.0 exportuje
51
+ `redactUrl` i `UNPARSEABLE_PLACEHOLDER` a všechny tři dependants míří na něj.
52
+ 4. **Je náhrada koncepčnější** — ano: `logger-contract` je L1 bez `@onlineapps`
53
+ závislostí, takže na něj dosáhne i kategorie `orchestration`, které brána G7
54
+ odmítá pin do kategorie `runtime`.
55
+
56
+ **Změna chování na jednom okraji:** zdejší kopie vracela `parsed.href` bez
57
+ úpravy, logger-contract navíc utne lomítko, které WHATWG dopisuje prázdné cestě
58
+ u *special* schemat (`http`, `https`, `ws`, `wss`, `ftp`, `file`). Na `amqp` /
59
+ `amqps` — jediných, se kterými tenhle klient pracuje — jsou obě implementace
60
+ bajtově shodné; `http://u:p@host:8080` nově vrátí `http://host:8080` místo
61
+ `http://host:8080/`.
62
+
63
+ Sada `tests/unit/redact-credentials.test.js`: blok se sémantikou `redactUrl`
64
+ case-by-case odešel (drží ho logger-contract), místo něj drží identitu
65
+ re-exportu, nepřítomnost druhé koleje v `utils/redactCredentials`
66
+ (`Object.keys` = `['redactConnectionTarget']`), shodu výstupu
67
+ `redactConnectionTarget` s `loggerContract.redactUrl` a cestu selhání.
68
+ Zmizely i mrtvé citace na `shared/service-common/src/redactUrl.js`
69
+ a `shared/connector/conn-orch-registry/src/redactUrl.js` — oba soubory už
70
+ neexistují.
71
+
7
72
  ## [3.0.1] — 2026-09-14
8
73
 
9
74
  - **Fixed: `consume()` accepts a TERMINAL queue — one this configuration dead-letters TO — instead of demanding a dead-letter route it must not have.** The d.259 gate refuses a queue for which `queueConfig` declares no `x-dead-letter-exchange`/`-routing-key`, because the policy ends a spent message with `nack(requeue=false)` and the broker drops what it cannot move. True of every working queue — and false of the queues that RECEIVE those moves: `workflow.failed` is named in `x-dead-letter-routing-key` by `workflow.init` and `workflow.control`, `<service>.dlq` by every business template, `workflow.dlq` and `delivery.dlq` by their own families, and the confirmed topology leaves exactly those without a route of their own (`api/docs/governance/confirmations/mq-consumer-contract.md` 002 point 2, 003 point 2 — "workflow.failed and workflow.dlq themselves keep no route"). A dead-letter route on a dead-letter queue is a loop or a chain with no end. So `workflow.failed` — the operator's queue, read by the delivery dispatcher — was unconsumable: `[BaseClient] Cannot consume from queue "workflow.failed": queueConfig declares no dead-letter route`. The gate now asks a second question, `queueConfig.isDeadLetterTarget(queue)`, **derived from the templates** (the set of every declared `x-dead-letter-routing-key`, `{service}` placeholder read through the same `templateShape()` the business templates use) rather than from a list of names that would have to be edited in step with them — the defect d.278 removed from `matchBusinessTemplate()`. The `.dlq` family needs no rule of its own: every `.dlq` this configuration declares is already the target of a declared routing key (measured 2026-09-14). Every other queue with no route is refused exactly as before, with the same sentence. A message that spends its budget on a terminal queue is rejected the same way and then DROPPED by the broker — the end of the chain is the end — never requeued for ever, and the loss is now logged at error level (`Message discarded on terminal queue "<queue>"`, with the queue, the attempts and the error) before the `message_dlq` event is published. Tests: new unit `consume-terminal-queue.test.js` (10 — the derivation from the declared keys, the terminal names and the non-terminal control, `consume('workflow.failed')` and `consume('<svc>.dlq')` attaching, the three refusals that must not change with the refusal verbatim, the discard with its log line and meta, and a routed queue as the control that says nothing about a discard); integration `dead-letter-route-required.integration.test.js` (+2 against the live broker: a message rejected from `<svc>.queue` reaches `<svc>.dlq` and is DELIVERED to a consumer there with `x-death[0].reason = 'rejected'`, and a handler failing on that terminal queue leaves the queue empty with no second delivery after 2 s — the drop no mock can measure). Three tests that encoded the old rule follow the new one: the `<svc>.dlq` refusal leg of `consume-dead-letter-route-required.test.js` (its subject moved to the new file), and the two integration control queues that used a dead-letter queue as "the queue with no route" (`delivery-events-lookup` → `monitoring.workflow`, `queue-declaration-options-one-rail` → `<svc>.events`).
@@ -36,7 +101,7 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
36
101
  - **Removed (BREAKING: `persistentBufferEnabled` and `persistentRedisClient` are refused by name):** the persistent publish buffer, which never existed. `buffer/RedisBuffer.js` was a placeholder — `add()` stored nothing, `flush()` returned 0 — and `MessageBuffer.add()` returned after that branch without falling back (the stub did not throw, so there was nothing to fall back from). With `persistentBufferEnabled: true` a critical-priority message that failed transiently was therefore DROPPED, while the same message at normal priority was held in memory and replayed after the reconnect: the key inverted its own promise. The package's own concept node already said so in writing (`docs/architecture/mq-publish-reliability.md` § Buffering: "RedisBuffer is a stub … Leave persistentBufferEnabled at its default") — a mechanism documented as not existing (`automation-gates.md` §5). The four questions of `change-discipline.md` § Removing are answered in the commit and in `tests/unit/persistent-buffer-removed.test.js`: it arrived with the publish-reliability layer (`3588b148`) as a hook for a future extension; no part of the concept requires persistence; nothing ever set either key outside one test (measured across `api`, `api_biz`, `infra`, `fe_adminui`, 2026-09-14); and what stands in its place — one in-memory buffer holding every priority — is more conceptual than a switch that loses messages. `getBufferState()` is therefore `{ size, inMemory }` (the `persistent` count could only ever be 0), `MessageBuffer.flush()` returns the number replayed instead of `{ inMemory, persistent }`, and both key names now fail the constructor with a sentence saying the mechanism is gone rather than a name to rename them to (d.343).
37
102
  - **Fixed (BREAKING: `publish()` no longer creates the exchange it publishes to, and `options.exchangeType` is gone):** an exchange is NAMED by a publisher, never declared by one. `_publishOnce()` asserted the exchange before EVERY publish, with arguments it made up on the spot — the type from `options.exchangeType || 'direct'` and the durability from the PUBLISHING client's own config — so the publisher decided the topology and two differently configured publishers declared one exchange two ways. Measured on the live broker: a client configured `durable: false` publishing to a fanout its owner declared durable is refused with `406 PRECONDITION-FAILED … inequivalent arg 'type' … received 'direct' but current is 'fanout'`, on a message that had nothing wrong with it. The owner's decision names this exact defect one object down — "the endpoint's own `{ durable: true }` assert is a second declaration and already yields 406 against the library's" (`docs/governance/confirmations/mq-consumer-contract.md` 003, point 4). Exchanges are declared once in `config/queueConfig.js` and asserted by their owners (measured: every exchange the platform publishes to already is), so an exchange nobody declared is now a broker-side 404 on the publish rather than an exchange invented by whoever published first — the same rule as `README.md` § Queue ownership states for queues. `options.exchangeType` existed for that one assert and left with it, including its only caller inside the library, `monitoring-publish.js` (d.342).
38
103
  - **Added:** `BaseClient.isConnectionFatal()` and `BaseClient.performHealthCheck()` — the last two methods a service could not reach. Same hole d.296 closed for the five counters, different question: both were methods of the TRANSPORT alone, and `new RabbitMQClient` is called in exactly one place in the whole workspace (`transports/transportFactory.js`, inside this library), so a service holding a `BaseClient` saw `isConnected() === false` with no way to learn whether to wait or to restart, and the manual probe `docs/architecture/rabbitmq-channel-lifecycle.md` documents was callable on nothing it holds. The transport keeps the computation; the client delegates. `performHealthCheck()` follows the five counters and refuses before `connect()` built a transport; `isConnectionFatal()` deliberately does not — a client that never connected has a true answer (no), and a healthcheck must not be answered by an exception. `onChannelClose` stays the transport's: it is an internal hook on a channel's lifetime, not a question a service asks. The d.296 gate now covers both new counterparts (d.341).
39
- - **Removed (BREAKING for a caller importing the transport):** `require('@onlineapps/mq-client-core').RabbitMQClient`. The four questions, answered before deleting: it came in with the library's extraction (`7a47b2fa`), as part of publishing the module's surface; no part of the concept ever carried it — `docs/architecture/mq-publish-reliability.md` describes the transport as what `BaseClient` selects, never as what a caller constructs; nothing reads it because `transportFactory` has been the one constructor since that same commit (measured across `api_biz/*`, `infra/*`, `shared/*`: zero uses outside this package); and what stands in its place is more conceptual, not less — the factory picks the transport from `type` (Configuration Over Convention), while constructing it by hand bypasses config composition, schema validation and the instance registry `disconnectAll()` is built on (d.341).
104
+ - **Removed (BREAKING for a caller importing the transport):** `require('@onlineapps/mq-client-core').RabbitMQClient`. The four questions, answered before deleting: it came in with the library's extraction (`7a47b2fa`), as part of publishing the module's surface; no part of the concept ever carried it — `docs/architecture/mq-publish-reliability.md` describes the transport as what `BaseClient` selects, never as what a caller constructs; nothing reads it because `transportFactory` has been the one constructor since that same commit (measured across `api_biz/*`, `infra/*`, `shared/*`: zero uses outside this package — that scope skipped `api/tests/**`, which carried 13 uses in 3 suites; INFRA retires them as a second rail, measured 2026-09-15); and what stands in its place is more conceptual, not less — the factory picks the transport from `type` (Configuration Over Convention), while constructing it by hand bypasses config composition, schema validation and the instance registry `disconnectAll()` is built on (d.341).
40
105
  - **Fixed (BREAKING: a spent reconnect budget is no longer the end of the client):** after the attempt budget of one recovery cycle runs out against a broker that never answered, the client **stands down** — `connection:standby` with `{ cycle, cyclesMax, attempts, lastError, timestamp }`, no timer, no socket, `isConnected()` false and `isConnectionFatal()` FALSE — and the next `publish()`, `consume()` or `performHealthCheck()` starts the next cycle. Nothing polls in between, so a client nobody uses costs nothing while the broker is away. Until now one spent budget set the fatal flag and the client refused every operation for the life of the process, which the README taught as "dead until the process restarts"; that is the "give up forever" the owner forbade (`docs/governance/confirmations/mq-client-lifecycle-contract.md` 001 point 3), and it meant a broker restart longer than `maxReconnectAttempts × reconnectMaxDelay` needed a process restart to undo. One rail: the lazy retry re-enters the same `_reconnectWithBackoff()`, there is no second loop (d.339).
41
106
  - **Added:** `maxReconnectCycles` (default 3, env `RABBITMQ_MAX_RECONNECT_CYCLES`) — how many recovery cycles the client runs before the connection IS permanently lost. It is what keeps the lazy retry finite; the value is the one `maxDeliveryAttempts` already answers the same question with (how many whole retries one thing gets), and two answers to one question must not differ (d.339).
42
107
  - **Changed (BREAKING for a caller reading the fatal error):** `connection:fatal` says WHICH of the two endings it is — `reason: 'broker-refused'` or `'cycles-spent'` — and carries `cycles` beside `attempts`, with a different sentence for each. A broker that ANSWERED and refused (403 `ACCESS-REFUSED`, 530 `NOT-ALLOWED`, 406 `PRECONDITION-FAILED`) is fatal on the spot, with no further attempt spent on an answer that cannot change: measured against amqplib 0.10.9 on the live broker (2026-09-14), such a refusal arrives as a plain `Error` with `code` undefined and the AMQP reply code rendered into the message, so the reply code is what is read — the broker's protocol constant, never wording invented here. Not recognised, and said rather than implied: a wrong vhost reaches amqplib as `Expected ConnectionOpenOk; got <ConnectionClose channel:0>`, so it is treated as an outage and ends at the cycle cap (d.339).
package/README.md CHANGED
@@ -565,6 +565,42 @@ file moves. Callers holding the deep path switch to the named export; callers th
565
565
  receive `queueConfig` by injection (`initInfrastructureQueues` in
566
566
  `@onlineapps/infrastructure-tools`) pass it from here.
567
567
 
568
+ ### topology — platform queue names, as a declared export
569
+
570
+ A name that travels ON THE WIRE between services has to have ONE owner, or it has one
571
+ copy per service. `topology` is that owner: a frozen object of the platform names, each
572
+ one composed from the entry that DECLARES it in `queueConfig`, so a rename there moves
573
+ the export with it and an entry that is gone fails fast instead of handing back a
574
+ plausible string.
575
+
576
+ ```javascript
577
+ const { topology } = require('@onlineapps/mq-client-core');
578
+
579
+ topology.infrastructureHealthEventsExchange; // 'infrastructure.health.events'
580
+ topology.telemetryLogsQueue; // 'telemetry.logs.queue'
581
+ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is frozen
582
+ ```
583
+
584
+ | Key | Name on the wire | Who publishes / who drains it |
585
+ |---|---|---|
586
+ | `infrastructureHealthEventsExchange` | `infrastructure.health.events` | Fanout. The registry publishes (`api/infra/api_services_registry/src/services/infrastructureEventPublisher.js`); the two queues below are bound to it. |
587
+ | `monitoringInfrastructureHealthEventsQueue` | `monitoring.infrastructure.health.events` | The monitoring consumer's queue on that fanout (`api/infra/api_monitoring/src/consumer/topology.js`, `MONITORING_INFRA_EVENT_QUEUE`). |
588
+ | `deliveryHealthEventsQueue` | `delivery.health.events` | The delivery endpoint binds it to the fanout and consumes it for WS push (`api/infra/api_delivery_endpoint/src/index.js`, `bindQueueToFanout(config.healthEventsQueue, …)`). |
589
+ | `deliveryResourceEventsQueue` | `delivery.resource.events` | Bound to the `monitoring.resource` exchange by `resource.changed.#`; published by the monitoring watcher `resourceChangedPublisher.js`, drained by `ResourceChangedConsumer.js` in the delivery endpoint. |
590
+ | `telemetryLogsQueue` | `telemetry.logs.queue` | Bound to `telemetry.exchange`, which every service publishes to with its own name as the routing key; drained by the monitoring consumer (`api/infra/api_monitoring/src/consumer/index.js`, `topology.TELEMETRY_LOGS_QUEUE`). |
591
+ | `telemetryMetricsQueue` | `telemetry.metrics.queue` | The same exchange, the same consumer — metric samples. |
592
+ | `telemetryTracesQueue` | `telemetry.traces.queue` | The same exchange, the same consumer — spans. |
593
+
594
+ - **Frozen on purpose.** An importer must not be able to rewrite platform topology: a
595
+ name overridden on one side of a fanout loses the events silently, which is why batch
596
+ 243g-C removed the per-service env override for the health-events exchange in the
597
+ first place.
598
+ - **The named export is the contract.** `@onlineapps/mq-client-core/src/config/queueConfig`
599
+ is an internal path this package never promised — it resolves today and breaks the day
600
+ the file moves. Import `topology` (or `queueConfig`) from the package root.
601
+ - A service that still carries one of these names as a private literal is carrying the
602
+ spelling of a fact declared here; importing it changes the wire by nothing at all.
603
+
568
604
  ### redactUrl — a connection URL that is safe to log
569
605
 
570
606
  `RABBITMQ_URL` carries the broker account, so anything that renders it verbatim
@@ -589,9 +625,10 @@ redactUrl(undefined); // '<unparseable-url>'
589
625
  goes through `redactConnectionTarget()` instead, which refuses a non-string
590
626
  outright, because `host` is a declared config key and a wrong type there is a
591
627
  boot-time defect.
592
- - It is the SAME function this package uses internally, and the same semantics as
593
- `shared/service-common/src/redactUrl.js`. A dependant that renders a broker URL
594
- imports it from here rather than keeping a copy.
628
+ - It is the SAME function this package uses internally. Implementaci vlastní
629
+ `@onlineapps/logger-contract` (vrstva L1, dosáhne na ni každá kategorie);
630
+ tenhle balíček ji od d.455 jen **re-exportuje identitou**, aby dependant,
631
+ který renderuje URL brokeru, importoval funkci místo kopie.
595
632
 
596
633
  ## Architecture
597
634
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
5
  "oa": {
6
6
  "category": "connector"
@@ -23,8 +23,8 @@
23
23
  "author": "OnlineApps",
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
- "@onlineapps/logger-contract": "1.1.0",
27
- "@onlineapps/runtime-config": "1.1.0",
26
+ "@onlineapps/logger-contract": "1.3.0",
27
+ "@onlineapps/runtime-config": "1.2.0",
28
28
  "ajv": "^8.12.0",
29
29
  "amqplib": "^0.10.3"
30
30
  },
package/src/index.js CHANGED
@@ -38,7 +38,7 @@ const {
38
38
  } = require('./monitoring-publish');
39
39
  const queueConfig = require('./config/queueConfig');
40
40
  const deliveryPolicy = require('./config/deliveryPolicy');
41
- const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('./utils/redactCredentials');
41
+ const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/logger-contract');
42
42
 
43
43
  // Export BaseClient as default (constructor), with additional named exports
44
44
  // NOTE: When destructuring, use: const { BaseClient } = require('@onlineapps/mq-client-core');
@@ -117,7 +117,18 @@ module.exports.topology = Object.freeze({
117
117
  * names (`api/docs/governance/confirmations/mq-consumer-contract.md` 003 — the
118
118
  * endpoint declaring its queues from `queueConfig` is INFRA's step).
119
119
  */
120
- deliveryResourceEventsQueue: queueConfig.queueName('deliveryEvents', 'resource.events')
120
+ deliveryResourceEventsQueue: queueConfig.queueName('deliveryEvents', 'resource.events'),
121
+ /**
122
+ * The monitoring consumer's OpenTelemetry log intake, bound to `telemetry.exchange`.
123
+ * Declared in `queueConfig` since d.269; the consumer carries the same literal as a
124
+ * private constant (`api/infra/api_monitoring/src/consumer/topology.js`), which is the
125
+ * shape d.270 gave an owner for the health-events names.
126
+ */
127
+ telemetryLogsQueue: queueConfig.queueName('telemetry', 'logs.queue'),
128
+ /** The same consumer's metric-sample intake on that exchange. */
129
+ telemetryMetricsQueue: queueConfig.queueName('telemetry', 'metrics.queue'),
130
+ /** The same consumer's span intake on that exchange. */
131
+ telemetryTracesQueue: queueConfig.queueName('telemetry', 'traces.queue')
121
132
  });
122
133
  /**
123
134
  * The dead-letter policy of `consume()` as a contract, not as an implementation
@@ -137,16 +148,17 @@ module.exports.deliveryPolicy = deliveryPolicy;
137
148
  * userinfo out, scheme/host/port/vhost in, and a fixed placeholder for a value that
138
149
  * is not a parseable URL.
139
150
  *
140
- * It is exported because it was already written three times here, in
141
- * `shared/service-common/src/redactUrl.js` (d.245/d.245b) and in
142
- * `shared/connector/conn-orch-registry/src/redactUrl.js` (d.446), whose header says
143
- * plainly that it stayed local only because this package declared no redactor and
144
- * masked the password alone. Two semantics for one concern are two rails
145
- * (`change-discipline.md` § One rail per concern); d.448 made them one and put it
146
- * where a dependant can import it instead of copying it.
151
+ * It is exported because it was already written three times across the platform.
152
+ * Two semantics for one concern are two rails (`change-discipline.md` § One rail
153
+ * per concern); d.448 made them one, a d.455 je přesunula tam, kam patří
154
+ * `@onlineapps/logger-contract` (vrstva L1, bez `@onlineapps` závislostí, dosáhne
155
+ * na ni každá kategorie). Zdejší export je od chvíle **re-export identitou**:
156
+ * zůstává, protože dependants ho importují odsud.
147
157
  *
148
158
  * The placeholder travels with the function: a caller comparing against it must
149
159
  * read the same literal, not retype it.
160
+ *
161
+ * @see node_modules/@onlineapps/logger-contract/src/redactUrl.js
150
162
  */
151
163
  module.exports.redactUrl = redactUrl;
152
164
  module.exports.UNPARSEABLE_PLACEHOLDER = UNPARSEABLE_PLACEHOLDER;
@@ -1784,7 +1784,22 @@ class RabbitMQClient extends EventEmitter {
1784
1784
  // The internal monitor calls `_performHealthCheck()` instead, deliberately:
1785
1785
  // a timer resuming recovery would make the retry periodic, which is the
1786
1786
  // opposite of the lazy contract.
1787
- this._resumeRecovery();
1787
+ //
1788
+ // "If this client stood down" is a QUESTION, and it is asked of the one
1789
+ // liveness source (`_connectionAlive`, read through `isConnected()` — d.260)
1790
+ // the two other uses already read it from: `_ensurePublisherChannel()` and
1791
+ // `_ensureConsumerChannel()` resume recovery inside their
1792
+ // `if (!this._connectionAlive)` branch. Until d.467 this one did not ask:
1793
+ // `_reconnectWithBackoff()` sets `_reconnecting` synchronously and
1794
+ // `isConnected()` reads it, so a check on a LIVE client reported its own
1795
+ // side effect — `connection: { exists: true, closed: true }`, the issue
1796
+ // `Connection is closed or missing`, `isConnected()` true before and false
1797
+ // after — and then really replaced a healthy socket with a new one. A
1798
+ // report that changes what it reports is not a report (INFRA 326b,
1799
+ // measured 2026-09-15 against the live broker, amqplib 0.10.9).
1800
+ if (!this.isConnected()) {
1801
+ this._resumeRecovery();
1802
+ }
1788
1803
  return this._performHealthCheck();
1789
1804
  }
1790
1805
 
@@ -12,53 +12,21 @@
12
12
  * boot, so the broker account was readable in container stdout and, through the
13
13
  * monitoring consumer, in Loki (measured 2026-09-07 on api_service_hello).
14
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.
15
+ * Samotné pravidlo „co smí z URL dorazit do logu“ tenhle balíček neimplementuje.
16
+ * Od d.455 je bere z `@onlineapps/logger-contract` (`redactUrl`,
17
+ * `UNPARSEABLE_PLACEHOLDER`): userinfo ven, scheme/host/port/vhost/query
18
+ * dovnitř, neparsovatelná hodnota nahrazená placeholderem místo echa. Do d.455
19
+ * tu stála bajtově stejná kopie jako v `@onlineapps/service-common` — tři
20
+ * vlastníci jednoho pravidla, tedy přesně to, co `change-discipline.md`
21
+ * § One rail per concern označuje za vadu.
25
22
  *
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.
23
+ * Zdejší zůstává jediná věc, kterou core vrstva nemá proč znát:
24
+ * `redactConnectionTarget`, fail-fast obal nad DEKLAROVANÝM config klíčem.
36
25
  *
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.
26
+ * @see node_modules/@onlineapps/logger-contract/src/redactUrl.js
40
27
  */
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
- }
28
+
29
+ const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/logger-contract');
62
30
 
63
31
  /**
64
32
  * Makes a connection target safe to log.
@@ -103,4 +71,4 @@ function redactConnectionTarget(target) {
103
71
  );
104
72
  }
105
73
 
106
- module.exports = { redactConnectionTarget, redactUrl, UNPARSEABLE_PLACEHOLDER };
74
+ module.exports = { redactConnectionTarget };