@onlineapps/service-common 2.0.0 → 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 +540 -0
- package/README.md +282 -22
- package/package.json +10 -6
- package/src/config.js +13 -1
- package/src/defaults.js +28 -1
- package/src/index.js +12 -27
- package/src/infrastructure/prefixedLogger.js +49 -0
- package/src/infrastructure/waitForHealthCheckQueueReady.js +47 -44
- package/src/infrastructure/waitForInfrastructureReady.js +88 -60
- package/src/jwt/createJwtValidator.js +159 -29
- package/src/jwt/verifyAccessToken.js +30 -11
- package/src/redactUrl.js +58 -0
- package/src/redisClient.js +175 -37
- package/src/registryReader.js +26 -42
- package/src/reporting/monitoringFallbackEmail.js +163 -28
- package/src/runtime-config.js +137 -29
- package/src/errors/BusinessError.js +0 -118
- package/src/errors/errorMiddleware.js +0 -112
- package/src/errors/index.js +0 -29
|
@@ -6,17 +6,34 @@ const ISSUER = 'oa-auth';
|
|
|
6
6
|
const MIN_SECRET_LENGTH = 16;
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Validate the signing secret the caller passed in.
|
|
10
|
+
*
|
|
11
|
+
* The secret is an INPUT of this library and never something it resolves for
|
|
12
|
+
* itself: a library that reads `process.env.JWT_SECRET` knows where the platform
|
|
13
|
+
* keeps its configuration, which is exactly what principle 1 (dependencies by
|
|
14
|
+
* constructor) and principle 8 (explicit over implicit) forbid. The RULE about
|
|
15
|
+
* the value — a string of at least MIN_SECRET_LENGTH characters, the minimum
|
|
16
|
+
* `JWT_AUTH.md` § Configuration states — stays here, because it is a property of
|
|
17
|
+
* the secret rather than of where it came from.
|
|
18
|
+
*
|
|
19
|
+
* Both public entry points validate against this one function: `createJwtValidator`
|
|
20
|
+
* at construction and `verifyAccessToken` at method entry (principle 4,
|
|
21
|
+
* fail-fast), so an instance can never be built with a secret a request would
|
|
22
|
+
* then be refused for.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} secret - HMAC-SHA256 signing secret, resolved by the caller
|
|
25
|
+
* @throws {Error} If the secret is absent, not a string, or shorter than the minimum
|
|
26
|
+
* @see api/docs/standards/JWT_AUTH.md § Configuration
|
|
11
27
|
*/
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
if (!secret || secret.length < MIN_SECRET_LENGTH) {
|
|
28
|
+
function assertSecret(secret) {
|
|
29
|
+
if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH) {
|
|
15
30
|
throw new Error(
|
|
16
|
-
`[JWT] Missing or insecure
|
|
31
|
+
`[JWT] Missing or insecure secret - Expected a string of at least ${MIN_SECRET_LENGTH} characters, `
|
|
32
|
+
+ 'passed in by the caller. Fix: resolve the platform key once at boot with '
|
|
33
|
+
+ 'requireEnv("JWT_SECRET", "HMAC-SHA256 signing secret", { file: "shared.env" }) and pass the '
|
|
34
|
+
+ 'value as verifyAccessToken(token, secret) / createJwtValidator({ secret }).'
|
|
17
35
|
);
|
|
18
36
|
}
|
|
19
|
-
return secret;
|
|
20
37
|
}
|
|
21
38
|
|
|
22
39
|
/**
|
|
@@ -27,15 +44,17 @@ function getJwtSecret() {
|
|
|
27
44
|
* Throws on any validation failure — callers decide how to handle.
|
|
28
45
|
*
|
|
29
46
|
* @param {string} token - Raw JWT string (without "Bearer " prefix)
|
|
47
|
+
* @param {string} secret - HMAC-SHA256 signing secret (see {@link assertSecret})
|
|
30
48
|
* @returns {object} Decoded payload: { sub, person_id, email, tenants, type, iss, iat, exp }
|
|
31
|
-
* @throws {Error} TokenExpiredError, JsonWebTokenError, or
|
|
49
|
+
* @throws {Error} TokenExpiredError, JsonWebTokenError, type mismatch, or an invalid secret
|
|
32
50
|
*/
|
|
33
|
-
function verifyAccessToken(token) {
|
|
51
|
+
function verifyAccessToken(token, secret) {
|
|
34
52
|
if (!token || typeof token !== 'string') {
|
|
35
53
|
throw new Error('[JWT] Token is required - Expected non-empty string');
|
|
36
54
|
}
|
|
37
55
|
|
|
38
|
-
|
|
56
|
+
assertSecret(secret);
|
|
57
|
+
|
|
39
58
|
const decoded = jwt.verify(token, secret, { issuer: ISSUER });
|
|
40
59
|
|
|
41
60
|
if (decoded.type !== 'access') {
|
|
@@ -47,4 +66,4 @@ function verifyAccessToken(token) {
|
|
|
47
66
|
return decoded;
|
|
48
67
|
}
|
|
49
68
|
|
|
50
|
-
module.exports = { verifyAccessToken,
|
|
69
|
+
module.exports = { verifyAccessToken, assertSecret, ISSUER, MIN_SECRET_LENGTH };
|
package/src/redactUrl.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Redaction of credentials carried in a connection URL.
|
|
5
|
+
*
|
|
6
|
+
* `REDIS_URL` is the single rail for the Redis credential (INFRA lead's decision
|
|
7
|
+
* 2026-09-10: one rail, no separate `REDIS_PASSWORD` in the shared env set).
|
|
8
|
+
* That makes every place which logs the URL verbatim a credential leak — and
|
|
9
|
+
* these logs go to Loki, so the leak is durable and searchable.
|
|
10
|
+
*
|
|
11
|
+
* `redactUrl` returns the same URL with the userinfo removed: what stays is the
|
|
12
|
+
* part a reader of the log actually needs — scheme, host, port, path.
|
|
13
|
+
*
|
|
14
|
+
* A value that is not a parseable URL is returned as a fixed placeholder rather
|
|
15
|
+
* than echoed: an unparseable string is exactly the case where nobody can say
|
|
16
|
+
* whether it holds a credential, and echoing it would be the leak this helper
|
|
17
|
+
* exists to prevent.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const UNPARSEABLE_PLACEHOLDER = '<unparseable-url>';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Strip userinfo (`user:password@`) from a connection URL.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} url - connection URL, e.g. `redis://:secret@cache:6379`
|
|
26
|
+
* @returns {string} the URL without userinfo, e.g. `redis://cache:6379`,
|
|
27
|
+
* or `<unparseable-url>` when the input is not a URL.
|
|
28
|
+
*/
|
|
29
|
+
function redactUrl(url) {
|
|
30
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
31
|
+
return UNPARSEABLE_PLACEHOLDER;
|
|
32
|
+
}
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = new URL(url);
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return UNPARSEABLE_PLACEHOLDER;
|
|
38
|
+
}
|
|
39
|
+
// `new URL('cache-host:6379')` SUCCEEDS — it reads `cache-host:` as the scheme
|
|
40
|
+
// and `6379` as an opaque path, so the value comes back with an empty host and
|
|
41
|
+
// no userinfo to strip. Measured 2026-09-11; without this guard such a value
|
|
42
|
+
// would be echoed verbatim, which is the leak this helper exists to prevent.
|
|
43
|
+
if (!parsed.host) {
|
|
44
|
+
return UNPARSEABLE_PLACEHOLDER;
|
|
45
|
+
}
|
|
46
|
+
parsed.username = '';
|
|
47
|
+
parsed.password = '';
|
|
48
|
+
// `URL.href` re-appends a trailing slash for an empty path; the URLs this is
|
|
49
|
+
// used on are host:port endpoints, and `redis://cache:6379/` is not the shape
|
|
50
|
+
// the rest of the platform writes.
|
|
51
|
+
const href = parsed.href;
|
|
52
|
+
return parsed.pathname === '/' && !url.endsWith('/') ? href.replace(/\/$/, '') : href;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
redactUrl,
|
|
57
|
+
UNPARSEABLE_PLACEHOLDER
|
|
58
|
+
};
|
package/src/redisClient.js
CHANGED
|
@@ -15,6 +15,48 @@
|
|
|
15
15
|
const { createClient } = require('redis');
|
|
16
16
|
const runtimeCfg = require('./config');
|
|
17
17
|
const { createRuntimeConfig } = require('@onlineapps/runtime-config');
|
|
18
|
+
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
19
|
+
const { redactUrl } = require('./redactUrl');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Share of the caller's `timeoutMs` that node-redis' OWN connect attempt gets;
|
|
23
|
+
* the remaining fifth is the reserve the hard race below keeps for itself. The
|
|
24
|
+
* value is derived from the budget the caller already passes — no second knob,
|
|
25
|
+
* no env key, and the deadline the caller asked for stays the deadline.
|
|
26
|
+
*
|
|
27
|
+
* Why the attempt must end FIRST: `RedisSocket#disconnect()` destroys only a
|
|
28
|
+
* socket it has already assigned (`@redis/client` `socket.js`
|
|
29
|
+
* `_RedisSocket_disconnect`), so a connect still in flight survives our
|
|
30
|
+
* `disconnect()` and keeps its TCPWRAP handle open for node-redis' default
|
|
31
|
+
* 5 s. What does destroy it is node-redis' own
|
|
32
|
+
* `socket.setTimeout(connectTimeout, () => socket.destroy(…))`, armed in
|
|
33
|
+
* `_RedisSocket_createSocket` and cleared again the moment the socket connects,
|
|
34
|
+
* so an established connection is never touched by it.
|
|
35
|
+
*
|
|
36
|
+
* A fifth is reserve enough: measured on @redis/client 1.6.1 (2026-09-14), the
|
|
37
|
+
* attempt rejects 3 ms after its configured deadline (40 ms → 43.0 ms and
|
|
38
|
+
* 42.3 ms, 200 ms → 203.3 ms and 201.8 ms, 1000 ms → 1001.0 ms), while the
|
|
39
|
+
* reserve is 10 ms at the smallest timeout this package calls with (50 ms in
|
|
40
|
+
* the unit tier) and 3 s at the 15 s default.
|
|
41
|
+
*/
|
|
42
|
+
const CONNECT_ATTEMPT_SHARE = 0.8;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Did node-redis end its own connect attempt on the deadline we gave it?
|
|
46
|
+
*
|
|
47
|
+
* It reports that as `ConnectionTimeoutError` ('Connection timeout') — a class
|
|
48
|
+
* it exports but never names on the instance: measured on @redis/client 1.6.1,
|
|
49
|
+
* `err.name` is 'Error' and only `err.constructor.name` carries the type. It is
|
|
50
|
+
* read off the instance rather than imported, so a test double of `redis` does
|
|
51
|
+
* not have to grow an export for this module to keep telling the truth about
|
|
52
|
+
* which deadline was hit.
|
|
53
|
+
*
|
|
54
|
+
* @param {*} err - The error the connect attempt rejected with
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
function isConnectDeadline(err) {
|
|
58
|
+
return Boolean(err) && Boolean(err.constructor) && err.constructor.name === 'ConnectionTimeoutError';
|
|
59
|
+
}
|
|
18
60
|
|
|
19
61
|
/**
|
|
20
62
|
* Build a Redis connection URL from environment.
|
|
@@ -53,87 +95,183 @@ function buildRedisUrl(options = {}) {
|
|
|
53
95
|
* @param {Object} options
|
|
54
96
|
* @param {string} [options.purpose] - Logical purpose (for log context)
|
|
55
97
|
* @param {boolean} [options.forTests=false] - If true, disables automatic reconnect loops
|
|
56
|
-
* @param {Object}
|
|
98
|
+
* @param {Object} options.logger - Logger with info/warn/error/debug (required)
|
|
57
99
|
* @param {Object} [options.env=process.env] - Optional env override (for tests)
|
|
58
100
|
* @param {Object} [options.defaults] - Optional default host/port overrides
|
|
59
|
-
* @
|
|
101
|
+
* @param {number} [options.connectAttemptTimeoutMs] - Deadline for ONE node-redis
|
|
102
|
+
* connect attempt (`socket.connectTimeout`). Left to node-redis' own default
|
|
103
|
+
* when absent. Not the same thing as the overall ceiling `connectRedis` keeps:
|
|
104
|
+
* a reconnect strategy may spend this budget once per attempt.
|
|
105
|
+
* @returns {{ client: import('redis').RedisClientType, url: string, loggedUrl: string }}
|
|
106
|
+
* `url` is the real connection URL, credential included — it goes to the
|
|
107
|
+
* client. `loggedUrl` is the same endpoint with the userinfo stripped, and it
|
|
108
|
+
* is the ONLY one of the two that may reach a log line or an error message.
|
|
60
109
|
*/
|
|
61
110
|
function createRedisClient(options = {}) {
|
|
62
111
|
const {
|
|
63
112
|
purpose = 'default',
|
|
64
113
|
forTests = false,
|
|
65
|
-
logger = console,
|
|
66
114
|
env,
|
|
67
|
-
redisUrl
|
|
115
|
+
redisUrl,
|
|
116
|
+
connectAttemptTimeoutMs
|
|
68
117
|
} = options;
|
|
69
118
|
|
|
119
|
+
// A redis 'error' event has nowhere else to go: the emitter swallows it, and
|
|
120
|
+
// an unhandled one takes the process down. That is the whole reason the logger
|
|
121
|
+
// is a hard requirement here (confirmation 001, 002).
|
|
122
|
+
const log = assertLogger(
|
|
123
|
+
'createRedisClient',
|
|
124
|
+
options.logger,
|
|
125
|
+
'the Redis error event this client swallows still reaches the structured log'
|
|
126
|
+
);
|
|
127
|
+
|
|
70
128
|
const url = buildRedisUrl({ env, redisUrl });
|
|
71
129
|
|
|
72
|
-
const socketOptions =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
130
|
+
const socketOptions = {};
|
|
131
|
+
if (forTests) {
|
|
132
|
+
// In tests we typically want fail-fast behaviour, not infinite reconnect loops
|
|
133
|
+
socketOptions.reconnectStrategy = false;
|
|
134
|
+
}
|
|
135
|
+
if (connectAttemptTimeoutMs !== undefined) {
|
|
136
|
+
// node-redis destroys the socket of an attempt that overruns this, which is
|
|
137
|
+
// the only thing that releases a connect still in flight. @see CONNECT_ATTEMPT_SHARE
|
|
138
|
+
socketOptions.connectTimeout = connectAttemptTimeoutMs;
|
|
139
|
+
}
|
|
78
140
|
|
|
79
141
|
const client = createClient({
|
|
80
142
|
url,
|
|
81
143
|
socket: socketOptions
|
|
82
144
|
});
|
|
83
145
|
|
|
84
|
-
|
|
146
|
+
// The URL carries the credential; the log carries the endpoint only.
|
|
147
|
+
// @see ./redactUrl.js
|
|
148
|
+
const loggedUrl = redactUrl(url);
|
|
85
149
|
|
|
86
150
|
client.on('error', (err) => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
});
|
|
93
|
-
} catch {
|
|
94
|
-
// Last-resort fallback to avoid throwing in error handler
|
|
95
|
-
// eslint-disable-next-line no-console
|
|
96
|
-
console.error('[Redis] Error', err);
|
|
97
|
-
}
|
|
151
|
+
log.error('[Redis] Error', {
|
|
152
|
+
purpose,
|
|
153
|
+
url: loggedUrl,
|
|
154
|
+
message: err && err.message ? err.message : String(err)
|
|
155
|
+
});
|
|
98
156
|
});
|
|
99
157
|
|
|
100
|
-
return { client, url };
|
|
158
|
+
return { client, url, loggedUrl };
|
|
101
159
|
}
|
|
102
160
|
|
|
103
161
|
/**
|
|
104
162
|
* Create and connect a Redis client with a hard timeout (useful for tests/tools).
|
|
105
163
|
*
|
|
164
|
+
* Every way the connect can fail — node-redis rejecting on its own, the hard
|
|
165
|
+
* race expiring — leaves through ONE message in the `[Context] Problem - Fix`
|
|
166
|
+
* shape, with the original error as `cause`.
|
|
167
|
+
*
|
|
106
168
|
* @param {Object} options - Same as createRedisClient plus:
|
|
107
|
-
* @param {number} [options.timeoutMs=15000] - Max time to wait for connect()
|
|
169
|
+
* @param {number} [options.timeoutMs=15000] - Max time to wait for connect().
|
|
170
|
+
* node-redis' own attempt is given `CONNECT_ATTEMPT_SHARE` of it, so the
|
|
171
|
+
* attempt ends — and releases its socket — inside this ceiling.
|
|
108
172
|
* @returns {Promise<import('redis').RedisClientType>}
|
|
109
173
|
*/
|
|
110
174
|
async function connectRedis(options = {}) {
|
|
111
|
-
const { timeoutMs = 15000
|
|
112
|
-
|
|
175
|
+
const { timeoutMs = 15000 } = options;
|
|
176
|
+
// Validated here as well as in createRedisClient, so the message names the
|
|
177
|
+
// entry point the caller actually used.
|
|
178
|
+
const logger = assertLogger(
|
|
179
|
+
'connectRedis',
|
|
180
|
+
options.logger,
|
|
181
|
+
'the established connection and the Redis error events are recorded'
|
|
182
|
+
);
|
|
183
|
+
const hasDeadline = Boolean(timeoutMs) && timeoutMs > 0;
|
|
184
|
+
// node-redis' own attempt ends a fifth of the budget BEFORE our hard race, so
|
|
185
|
+
// the socket it opened is destroyed by the library that owns it — our
|
|
186
|
+
// `disconnect()` below cannot reach a socket that is not assigned yet.
|
|
187
|
+
// @see CONNECT_ATTEMPT_SHARE
|
|
188
|
+
const connectAttemptTimeoutMs = hasDeadline
|
|
189
|
+
? Math.max(1, Math.floor(timeoutMs * CONNECT_ATTEMPT_SHARE))
|
|
190
|
+
: undefined;
|
|
191
|
+
// `url` carries the credential and is never rendered anywhere below;
|
|
192
|
+
// `loggedUrl` is the endpoint form and is what every message here uses.
|
|
193
|
+
const { client, loggedUrl } = createRedisClient({ ...options, connectAttemptTimeoutMs });
|
|
113
194
|
|
|
114
|
-
if (!
|
|
195
|
+
if (!hasDeadline) {
|
|
115
196
|
await client.connect();
|
|
116
|
-
|
|
117
|
-
logger.info('[Redis] Connected', { url });
|
|
118
|
-
}
|
|
197
|
+
logger.info('[Redis] Connected', { url: loggedUrl });
|
|
119
198
|
return client;
|
|
120
199
|
}
|
|
121
200
|
|
|
122
201
|
const connectPromise = client.connect();
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
202
|
+
let raceLost = false;
|
|
203
|
+
let timeoutHandle;
|
|
204
|
+
// The race arm carries no message of its own — it only says which arm won.
|
|
205
|
+
// Every failure then leaves this function through the single throw below, so
|
|
206
|
+
// the contract message has one owner and node-redis' bare 'Connection
|
|
207
|
+
// timeout' never reaches a caller.
|
|
208
|
+
const hardDeadline = new Promise((resolve) => {
|
|
209
|
+
timeoutHandle = setTimeout(
|
|
210
|
+
() => {
|
|
211
|
+
raceLost = true;
|
|
212
|
+
resolve();
|
|
213
|
+
},
|
|
126
214
|
timeoutMs
|
|
127
215
|
);
|
|
128
216
|
});
|
|
129
217
|
|
|
130
|
-
|
|
218
|
+
let cause;
|
|
219
|
+
try {
|
|
220
|
+
await Promise.race([connectPromise, hardDeadline]);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
cause = err;
|
|
223
|
+
} finally {
|
|
224
|
+
// Promise.race settles on the first arm; the loser keeps running. Without
|
|
225
|
+
// this the process held the timer for the full timeoutMs after a connection
|
|
226
|
+
// that had already succeeded — long enough to keep the event loop alive.
|
|
227
|
+
clearTimeout(timeoutHandle);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!raceLost && cause === undefined) {
|
|
231
|
+
logger.info('[Redis] Connected', { url: loggedUrl });
|
|
232
|
+
return client;
|
|
233
|
+
}
|
|
131
234
|
|
|
132
|
-
|
|
133
|
-
|
|
235
|
+
// The connect arm keeps running after it loses the race — with node-redis'
|
|
236
|
+
// default reconnect strategy it keeps retrying — and the caller never
|
|
237
|
+
// receives the client on this path, so this function is the only place that
|
|
238
|
+
// can release the socket it opened. `isOpen` is false when connect() failed
|
|
239
|
+
// on its own (node-redis closed it already) and disconnecting then throws.
|
|
240
|
+
if (client.isOpen) {
|
|
241
|
+
try {
|
|
242
|
+
await client.disconnect();
|
|
243
|
+
} catch (disconnectErr) {
|
|
244
|
+
// Reported, never swallowed — and never in place of the original
|
|
245
|
+
// failure, which is what the caller asked about.
|
|
246
|
+
logger.warn('[Redis] Releasing the failed connection failed', {
|
|
247
|
+
url: loggedUrl,
|
|
248
|
+
message: disconnectErr && disconnectErr.message
|
|
249
|
+
? disconnectErr.message
|
|
250
|
+
: String(disconnectErr)
|
|
251
|
+
});
|
|
252
|
+
}
|
|
134
253
|
}
|
|
135
254
|
|
|
136
|
-
|
|
255
|
+
// One shape for every way a connect can fail, with the original kept as
|
|
256
|
+
// `cause` rather than interpolated — interpolating copies the text and throws
|
|
257
|
+
// the stack away. The verdict is the one fact that differs: a deadline (ours,
|
|
258
|
+
// or the one we gave node-redis) is not the same event as a refused
|
|
259
|
+
// connection, and a message calling it one would be untrue.
|
|
260
|
+
const verdict = raceLost || isConnectDeadline(cause)
|
|
261
|
+
? `timeout after ${timeoutMs}ms`
|
|
262
|
+
: 'failed';
|
|
263
|
+
// node-redis builds its messages from host:port and never renders the URL it
|
|
264
|
+
// was handed, so the credential cannot arrive through here.
|
|
265
|
+
const reason = cause === undefined
|
|
266
|
+
? 'the client never reported ready'
|
|
267
|
+
: (cause.message ? cause.message : String(cause));
|
|
268
|
+
|
|
269
|
+
throw new Error(
|
|
270
|
+
`[service-common][redisClient] Redis connection ${verdict} to ${loggedUrl} - ${reason}. `
|
|
271
|
+
+ 'Fix: check that Redis is running at that URL, or raise timeoutMs if the instance '
|
|
272
|
+
+ 'is merely slow to accept.',
|
|
273
|
+
{ cause }
|
|
274
|
+
);
|
|
137
275
|
}
|
|
138
276
|
|
|
139
277
|
module.exports = {
|
package/src/registryReader.js
CHANGED
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
* @see api/docs/standards/redis-key-contract.md — registry namespace
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
const {
|
|
32
|
+
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
33
|
+
const { connectRedis } = require('./redisClient');
|
|
33
34
|
const DEFAULTS = require('./defaults');
|
|
34
35
|
|
|
35
36
|
const CONTEXT = '[service-common][registryReader]';
|
|
@@ -45,18 +46,6 @@ function requireKeyPrefix(keyPrefix) {
|
|
|
45
46
|
return keyPrefix;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
function requireLogger(logger) {
|
|
49
|
-
const missing = ['info', 'warn', 'error'].filter(
|
|
50
|
-
(method) => !logger || typeof logger[method] !== 'function'
|
|
51
|
-
);
|
|
52
|
-
if (missing.length > 0) {
|
|
53
|
-
throw new Error(
|
|
54
|
-
`${CONTEXT} Missing dependency - logger is required and must implement ${missing.join(', ')} `
|
|
55
|
-
+ '(no console fallback). Fix: inject the service logger.'
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
return logger;
|
|
59
|
-
}
|
|
60
49
|
|
|
61
50
|
function requireTtl(value, name, fallbackDefault) {
|
|
62
51
|
if (value === undefined) return fallbackDefault;
|
|
@@ -112,7 +101,8 @@ function parseOrThrow(raw, key, what) {
|
|
|
112
101
|
* @param {string} options.keyPrefix Registry key namespace (REQUIRED, no default)
|
|
113
102
|
* @param {string} [options.redisUrl] Redis connection URL
|
|
114
103
|
* @param {object} [options.client] Connected node-redis v4 client
|
|
115
|
-
* @param {object} options.logger Logger implementing info/warn/error (REQUIRED
|
|
104
|
+
* @param {object} options.logger Logger implementing info/warn/error/debug (REQUIRED,
|
|
105
|
+
* `@onlineapps/logger-contract`; no console fallback)
|
|
116
106
|
* @param {number} [options.ttlMs] TTL of a cached spec (default: defaults.registryReaderTtlMs)
|
|
117
107
|
* @param {number} [options.negativeTtlMs] TTL of a cached "not registered" (default: defaults.registryReaderNegativeTtlMs)
|
|
118
108
|
* @returns {object} reader
|
|
@@ -149,7 +139,16 @@ function createRegistryReader(options) {
|
|
|
149
139
|
requireClientContract(client);
|
|
150
140
|
}
|
|
151
141
|
|
|
152
|
-
|
|
142
|
+
// The platform's logger contract has ONE owner (`@onlineapps/logger-contract`,
|
|
143
|
+
// confirmation `connector-logger-contract` 001/004): four methods, validated
|
|
144
|
+
// up front. The reader used to re-state a three-method variant of it here —
|
|
145
|
+
// the last local copy in this package — which `connectRedis` could not honour,
|
|
146
|
+
// because the client it creates logs through the same object.
|
|
147
|
+
const log = assertLogger(
|
|
148
|
+
'createRegistryReader',
|
|
149
|
+
logger,
|
|
150
|
+
'the registry reads and the Redis error events of the connection it owns reach the structured log'
|
|
151
|
+
);
|
|
153
152
|
const positiveTtl = requireTtl(ttlMs, 'ttlMs', DEFAULTS.registryReaderTtlMs);
|
|
154
153
|
const negativeTtl = requireTtl(negativeTtlMs, 'negativeTtlMs', DEFAULTS.registryReaderNegativeTtlMs);
|
|
155
154
|
const connectTimeout = requireTtl(
|
|
@@ -180,34 +179,19 @@ function createRegistryReader(options) {
|
|
|
180
179
|
if (connected) return;
|
|
181
180
|
|
|
182
181
|
if (ownsClient) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
// Opening a Redis connection with a ceiling is ONE mechanism and it lives
|
|
183
|
+
// in ./redisClient.js: the ceiling the caller asked for, the attempt
|
|
184
|
+
// deadline derived from it (so node-redis destroys its own socket inside
|
|
185
|
+
// that ceiling), the unified 'error' logging with the userinfo stripped,
|
|
186
|
+
// and one contract message carrying the original error as `cause`. The
|
|
187
|
+
// reader states the budget and nothing else.
|
|
188
|
+
// @see ./redisClient.js connectRedis
|
|
189
|
+
redis = await connectRedis({
|
|
190
|
+
purpose: 'registryReader',
|
|
191
|
+
redisUrl,
|
|
192
|
+
logger: log,
|
|
193
|
+
timeoutMs: connectTimeout
|
|
186
194
|
});
|
|
187
|
-
|
|
188
|
-
// node-redis keeps retrying a refused connection forever, so the initial
|
|
189
|
-
// connect gets its own hard ceiling — startup must fail, not hang.
|
|
190
|
-
let timeoutHandle = null;
|
|
191
|
-
const deadline = new Promise((_resolve, reject) => {
|
|
192
|
-
timeoutHandle = setTimeout(() => reject(new Error(
|
|
193
|
-
`${CONTEXT} Redis connection timeout - no connection to ${redisUrl} within ${connectTimeout}ms. `
|
|
194
|
-
+ 'Fix: check the Redis address and that the instance is running.'
|
|
195
|
-
)), connectTimeout);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
try {
|
|
199
|
-
await Promise.race([created.connect(), deadline]);
|
|
200
|
-
} catch (err) {
|
|
201
|
-
try {
|
|
202
|
-
await created.disconnect();
|
|
203
|
-
} catch (_ignored) {
|
|
204
|
-
// The client never opened; nothing to release.
|
|
205
|
-
}
|
|
206
|
-
throw err;
|
|
207
|
-
} finally {
|
|
208
|
-
clearTimeout(timeoutHandle);
|
|
209
|
-
}
|
|
210
|
-
redis = created;
|
|
211
195
|
}
|
|
212
196
|
|
|
213
197
|
connected = true;
|