queen-mq 0.16.0 → 1.0.3
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/README.md +175 -0
- package/client-v2/Queen.js +125 -15
- package/client-v2/README.md +69 -0
- package/client-v2/builders/QueueBuilder.js +18 -20
- package/client-v2/builders/TimerBuilder.js +262 -0
- package/client-v2/builders/TransactionBuilder.js +199 -10
- package/client-v2/consumer/ConsumerManager.js +64 -12
- package/client-v2/http/HttpClient.js +382 -32
- package/client-v2/kv/Kv.js +432 -0
- package/client-v2/kv/expiry.js +148 -0
- package/client-v2/streams/runtime/Runner.js +45 -0
- package/client-v2/utils/defaults.js +20 -1
- package/package.json +12 -3
- package/test-v2/_kvtimers.js +71 -0
- package/test-v2/ackwindow.js +265 -0
- package/test-v2/auth.js +65 -149
- package/test-v2/docs.js +204 -0
- package/test-v2/http-unit/hostHeader.test.js +411 -0
- package/test-v2/http-unit/retry429.test.js +319 -0
- package/test-v2/kv-unit/_planServer.js +65 -0
- package/test-v2/kv-unit/kvWire.test.js +377 -0
- package/test-v2/kv-unit/timerWire.test.js +177 -0
- package/test-v2/kv-unit/txnWire.test.js +222 -0
- package/test-v2/kv.js +273 -0
- package/test-v2/load.js +37 -41
- package/test-v2/maintenance.js +2 -2
- package/test-v2/push.js +25 -35
- package/test-v2/run.js +67 -5
- package/test-v2/semantics.js +801 -0
- package/test-v2/stream/_helpers.js +8 -1
- package/test-v2/stream/combined.js +4 -3
- package/test-v2/stream/cron.js +1 -1
- package/test-v2/stream/eventTime.js +8 -5
- package/test-v2/stream/operators.js +5 -5
- package/test-v2/stream/recovery.js +4 -1
- package/test-v2/stream/session.js +3 -3
- package/test-v2/stream/sliding.js +1 -1
- package/test-v2/stream/throughput.js +3 -2
- package/test-v2/stream/tumbling.js +16 -12
- package/test-v2/streams-unit/ack.test.js +203 -0
- package/test-v2/streams-unit/e2e.test.js +4 -1
- package/test-v2/timers.js +209 -0
- package/test-v2/transaction.js +4 -1
- package/test-v2/watermark.js +78 -62
|
@@ -4,6 +4,80 @@
|
|
|
4
4
|
|
|
5
5
|
import * as logger from '../utils/logger.js'
|
|
6
6
|
|
|
7
|
+
// --------------------------------------------------------------------------
|
|
8
|
+
// Host-routed proxy support (queen_proxy selects the tenant cluster from the
|
|
9
|
+
// first DNS label of the Host header -- proxy/src/cache.rs
|
|
10
|
+
// slug_from_host).
|
|
11
|
+
//
|
|
12
|
+
// `fetch()` refuses to send a caller-supplied Host header: `host` is on the
|
|
13
|
+
// WHATWG forbidden-header list, so undici drops it *silently*. Sending the
|
|
14
|
+
// wrong Host is not a cosmetic problem at a proxy -- it either 421s
|
|
15
|
+
// (cluster_unknown) or, on a cell with a default cluster, lands the traffic on
|
|
16
|
+
// somebody else's cluster. So the Host is never taken from `headers`; it is a
|
|
17
|
+
// first-class option (`hostHeader`) that rewrites the request URL's authority
|
|
18
|
+
// while the connection stays pinned to the configured address (the same thing
|
|
19
|
+
// `curl --resolve` does), and a Host found in `headers` is mapped onto it with
|
|
20
|
+
// a loud warning rather than being ignored.
|
|
21
|
+
// --------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
// Warn once per distinct offending value: a second cluster misconfigured the
|
|
24
|
+
// same way must not be masked by the first. Bounded so a pathological caller
|
|
25
|
+
// can't grow it without limit (past the cap every occurrence warns, which is
|
|
26
|
+
// the safe direction).
|
|
27
|
+
const WARNED_HOST_TRAPS = new Set()
|
|
28
|
+
const WARNED_HOST_TRAPS_CAP = 64
|
|
29
|
+
|
|
30
|
+
function warnHostTrap(key, message) {
|
|
31
|
+
logger.warn('HttpClient.hostHeader', message)
|
|
32
|
+
if (WARNED_HOST_TRAPS.has(key)) return
|
|
33
|
+
if (WARNED_HOST_TRAPS.size < WARNED_HOST_TRAPS_CAP) WARNED_HOST_TRAPS.add(key)
|
|
34
|
+
// Deliberately not gated behind QUEEN_CLIENT_LOG: a request that reaches the
|
|
35
|
+
// wrong tenant must never be quiet.
|
|
36
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
37
|
+
console.warn(`[queen-mq] ${message}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate/parse a `hostHeader` value into the pieces we need.
|
|
43
|
+
* Accepts a bare authority: 'acme', 'acme.eu1.queenmq.cloud', 'acme.local:6711',
|
|
44
|
+
* '[::1]:6711'. Rejects anything URL-shaped so a mistake fails at construction
|
|
45
|
+
* instead of routing somewhere unexpected at runtime.
|
|
46
|
+
*/
|
|
47
|
+
function parseHostOverride(value) {
|
|
48
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
49
|
+
throw new Error("hostHeader must be a non-empty string, e.g. hostHeader: 'acme.eu1.queenmq.cloud'")
|
|
50
|
+
}
|
|
51
|
+
const authority = value.trim()
|
|
52
|
+
if (authority.includes('://') || /[\s/\\?#@]/.test(authority)) {
|
|
53
|
+
throw new Error(`hostHeader must be a bare authority ('acme.eu1.queenmq.cloud' or 'acme.local:6711'), not a URL — got '${authority}'`)
|
|
54
|
+
}
|
|
55
|
+
let parsed
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(`http://${authority}`)
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error(`hostHeader is not a valid host[:port] — got '${authority}'`)
|
|
60
|
+
}
|
|
61
|
+
if (!parsed.hostname || parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
62
|
+
throw new Error(`hostHeader is not a valid host[:port] — got '${authority}'`)
|
|
63
|
+
}
|
|
64
|
+
return { authority, hostname: parsed.hostname, port: parsed.port }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Split a user headers object into { hostValue, headers-without-Host }. */
|
|
68
|
+
function splitHostHeader(headers) {
|
|
69
|
+
const rest = {}
|
|
70
|
+
let hostValue = null
|
|
71
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
72
|
+
if (key.toLowerCase() === 'host') {
|
|
73
|
+
hostValue = typeof value === 'string' ? value.trim() : String(value)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
rest[key] = value
|
|
77
|
+
}
|
|
78
|
+
return { hostValue: hostValue || null, headers: rest }
|
|
79
|
+
}
|
|
80
|
+
|
|
7
81
|
export class HttpClient {
|
|
8
82
|
#baseUrl
|
|
9
83
|
#loadBalancer
|
|
@@ -13,6 +87,20 @@ export class HttpClient {
|
|
|
13
87
|
#enableFailover
|
|
14
88
|
#bearerToken
|
|
15
89
|
#headers
|
|
90
|
+
#retry429
|
|
91
|
+
// Per-client undici Agent (Node only). Owning our own dispatcher is what
|
|
92
|
+
// makes Queen.close() deterministic: global-fetch keep-alive sockets belong
|
|
93
|
+
// to the process-wide dispatcher and can't be released per client, which
|
|
94
|
+
// left the event loop pinned after close(). `undefined` = not yet resolved,
|
|
95
|
+
// `null` = unavailable (browser / import failed) -> fall back to global fetch.
|
|
96
|
+
#dispatcher = undefined
|
|
97
|
+
#destroyed = false
|
|
98
|
+
// Host-routed proxy support: parsed `hostHeader` ({authority, hostname, port})
|
|
99
|
+
// or null, plus one connection-pinned Agent per real backend (`host:port` ->
|
|
100
|
+
// Agent) so load balancing still reaches each backend while every request
|
|
101
|
+
// carries the same virtual Host.
|
|
102
|
+
#hostOverride = null
|
|
103
|
+
#pinnedDispatchers = new Map()
|
|
16
104
|
|
|
17
105
|
constructor(options = {}) {
|
|
18
106
|
const {
|
|
@@ -23,7 +111,24 @@ export class HttpClient {
|
|
|
23
111
|
retryDelayMillis = 1000,
|
|
24
112
|
enableFailover = true,
|
|
25
113
|
bearerToken = null,
|
|
26
|
-
headers = {}
|
|
114
|
+
headers = {},
|
|
115
|
+
// Host to advertise, independent of the address we connect to. A
|
|
116
|
+
// queen_proxy deployment resolves the tenant cluster from the Host
|
|
117
|
+
// header's first DNS label, so this is what selects a cluster when the
|
|
118
|
+
// base URL points at a shared address (an IP, a cell endpoint, a test
|
|
119
|
+
// rig). The connection still goes to baseUrl/loadBalancer; only the
|
|
120
|
+
// request's authority (and TLS SNI) is rewritten. Normally unnecessary
|
|
121
|
+
// in production, where each cluster has its own subdomain and the base
|
|
122
|
+
// URL already carries the right Host.
|
|
123
|
+
hostHeader = null,
|
|
124
|
+
// 429 (rate-limited) backoff policy, separate from the 5xx/network
|
|
125
|
+
// retryAttempts above. { maxAttempts, baseMs, capMs }, all optional.
|
|
126
|
+
// maxAttempts defaults to 10 for ordinary requests; long-poll pop
|
|
127
|
+
// calls (marked via the `retryKind: 'pop'` request option) default to
|
|
128
|
+
// unbounded (retry forever, paced by backoff) unless maxAttempts is
|
|
129
|
+
// explicitly set here, in which case it applies to both.
|
|
130
|
+
// See PLAN_QUEEN_PROXY_CLOUD.md §4/§9 (client 429 backoff, blocker B4).
|
|
131
|
+
retry429 = {}
|
|
27
132
|
} = options
|
|
28
133
|
|
|
29
134
|
this.#baseUrl = baseUrl
|
|
@@ -33,23 +138,195 @@ export class HttpClient {
|
|
|
33
138
|
this.#retryDelayMillis = retryDelayMillis
|
|
34
139
|
this.#enableFailover = enableFailover
|
|
35
140
|
this.#bearerToken = bearerToken
|
|
36
|
-
this.#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
141
|
+
this.#retry429 = retry429 || {}
|
|
142
|
+
|
|
143
|
+
// A Host in `headers` can never go out over fetch(); rather than let it be
|
|
144
|
+
// dropped, map it onto the supported mechanism and say so out loud.
|
|
145
|
+
const { hostValue, headers: plainHeaders } = splitHostHeader(headers)
|
|
146
|
+
this.#headers = plainHeaders
|
|
147
|
+
let effectiveHost = hostHeader
|
|
148
|
+
if (hostValue) {
|
|
149
|
+
if (!effectiveHost) {
|
|
150
|
+
effectiveHost = hostValue
|
|
151
|
+
warnHostTrap(`map:${hostValue}`, `headers: { Host: '${hostValue}' } cannot be sent by fetch() (Host is a forbidden header name); it has been mapped onto hostHeader: '${hostValue}'. Set hostHeader directly to silence this warning.`)
|
|
152
|
+
} else if (String(effectiveHost).trim().toLowerCase() !== hostValue.toLowerCase()) {
|
|
153
|
+
warnHostTrap(`conflict:${hostValue}=>${effectiveHost}`, `headers: { Host: '${hostValue}' } is ignored because hostHeader: '${effectiveHost}' is also configured — requests will carry Host: '${effectiveHost}'.`)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
this.#hostOverride = effectiveHost ? parseHostOverride(effectiveHost) : null
|
|
157
|
+
|
|
158
|
+
logger.log('HttpClient.constructor', {
|
|
159
|
+
hasLoadBalancer: !!loadBalancer,
|
|
160
|
+
baseUrl: baseUrl || 'load-balanced',
|
|
161
|
+
timeoutMillis,
|
|
42
162
|
retryAttempts,
|
|
43
163
|
enableFailover,
|
|
44
164
|
hasAuth: !!bearerToken,
|
|
45
|
-
customHeaders: Object.keys(this.#headers).length
|
|
165
|
+
customHeaders: Object.keys(this.#headers).length,
|
|
166
|
+
hostHeader: this.#hostOverride ? this.#hostOverride.authority : null,
|
|
167
|
+
retry429: this.#retry429
|
|
46
168
|
})
|
|
47
169
|
}
|
|
48
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Resolve the effective 429 retry policy for a request kind.
|
|
173
|
+
* - 'pop': long-poll pop (wait=true). Unbounded attempts by default (the
|
|
174
|
+
* long-poll loop is meant to keep waiting through transient rate
|
|
175
|
+
* limiting), unless retry429.maxAttempts was explicitly configured.
|
|
176
|
+
* - anything else (push, admin calls, non-waiting pop, ...): bounded,
|
|
177
|
+
* defaults to 10 attempts.
|
|
178
|
+
* baseMs/capMs always default to 500/30000 and apply to both kinds.
|
|
179
|
+
*/
|
|
180
|
+
#retry429PolicyFor(retryKind) {
|
|
181
|
+
const cfg = this.#retry429 || {}
|
|
182
|
+
const baseMs = cfg.baseMs ?? 500
|
|
183
|
+
const capMs = cfg.capMs ?? 30000
|
|
184
|
+
const maxAttempts = cfg.maxAttempts != null
|
|
185
|
+
? cfg.maxAttempts
|
|
186
|
+
: (retryKind === 'pop' ? Infinity : 10)
|
|
187
|
+
return { maxAttempts, baseMs, capMs }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Delay before the next 429 retry attempt (milliseconds).
|
|
192
|
+
* Honors Retry-After (seconds) when the server sent one, with ±20%
|
|
193
|
+
* jitter to avoid a synchronized thundering herd; otherwise falls back to
|
|
194
|
+
* exponential backoff (baseMs * 2^attempt, capped at capMs), also
|
|
195
|
+
* jittered ±20% for the same reason.
|
|
196
|
+
*/
|
|
197
|
+
#computeRetry429DelayMs(attemptIndex, retryAfterSeconds, baseMs, capMs) {
|
|
198
|
+
const hasRetryAfter = typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0
|
|
199
|
+
const baseDelay = hasRetryAfter
|
|
200
|
+
? retryAfterSeconds * 1000
|
|
201
|
+
: Math.min(capMs, baseMs * Math.pow(2, attemptIndex))
|
|
202
|
+
const jitterMultiplier = 1 + (Math.random() * 0.4 - 0.2) // ±20%
|
|
203
|
+
return Math.max(0, Math.round(baseDelay * jitterMultiplier))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Run a single logical request against one URL, transparently retrying
|
|
208
|
+
* HTTP 429 responses with backoff until the policy for `retryKind` is
|
|
209
|
+
* exhausted (or never, for unbounded pop policies). Any other error
|
|
210
|
+
* (2xx-and-parse success aside) is thrown straight through — 429 is the
|
|
211
|
+
* only status this layer treats as retryable; 5xx/network retry and
|
|
212
|
+
* cross-backend failover are handled by the caller.
|
|
213
|
+
*/
|
|
214
|
+
async #executeWithRetry429(url, method, body, requestTimeoutMillis, retryKind) {
|
|
215
|
+
const { maxAttempts, baseMs, capMs } = this.#retry429PolicyFor(retryKind)
|
|
216
|
+
let tries = 0
|
|
217
|
+
// eslint-disable-next-line no-constant-condition
|
|
218
|
+
while (true) {
|
|
219
|
+
tries++
|
|
220
|
+
try {
|
|
221
|
+
return await this.#executeRequest(url, method, body, requestTimeoutMillis)
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error.status !== 429) throw error
|
|
224
|
+
|
|
225
|
+
if (tries >= maxAttempts) {
|
|
226
|
+
logger.error('HttpClient.retry429', { method, url, error: 'max 429 attempts exhausted', attempts: tries, code: error.code })
|
|
227
|
+
throw error
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const delay = this.#computeRetry429DelayMs(tries - 1, error.retryAfterSeconds, baseMs, capMs)
|
|
231
|
+
logger.warn('HttpClient.retry429', { method, url, attempt: tries, retryKind: retryKind || 'default', nextDelayMs: delay, retryAfterSeconds: error.retryAfterSeconds ?? null, code: error.code ?? null })
|
|
232
|
+
await new Promise(resolve => setTimeout(resolve, delay))
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Resolve the per-client dispatcher once: undici Agent on Node, null in
|
|
238
|
+
// browsers (global fetch used as before). undici is a regular dependency,
|
|
239
|
+
// but the guarded dynamic import keeps browser bundles working.
|
|
240
|
+
async #getDispatcher() {
|
|
241
|
+
if (this.#dispatcher !== undefined) return this.#dispatcher
|
|
242
|
+
try {
|
|
243
|
+
const { Agent } = await import('undici')
|
|
244
|
+
this.#dispatcher = new Agent({ keepAliveTimeout: 4000, keepAliveMaxTimeout: 30000 })
|
|
245
|
+
} catch {
|
|
246
|
+
this.#dispatcher = null
|
|
247
|
+
}
|
|
248
|
+
return this.#dispatcher
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Agent whose connector always dials `realHostname:realPort`, whatever
|
|
253
|
+
* authority the request URL carries — so the URL (and therefore the Host
|
|
254
|
+
* header, and TLS SNI) can name the tenant cluster while the socket still
|
|
255
|
+
* goes to the configured address. One Agent per real backend keeps load
|
|
256
|
+
* balancing and failover intact. Returns null when undici is unavailable
|
|
257
|
+
* (browser) or the client was destroyed; the caller must then refuse to
|
|
258
|
+
* send rather than fall back to a wrong Host.
|
|
259
|
+
*/
|
|
260
|
+
#getPinnedDispatcher(realHostname, realPort) {
|
|
261
|
+
const key = `${realHostname}:${realPort}`
|
|
262
|
+
const existing = this.#pinnedDispatchers.get(key)
|
|
263
|
+
if (existing) return existing
|
|
264
|
+
if (this.#destroyed) return Promise.resolve(null)
|
|
265
|
+
// Cache the in-flight promise, not the resolved Agent: concurrent first
|
|
266
|
+
// requests to the same backend must share one Agent, or the extra ones
|
|
267
|
+
// would hold keep-alive sockets that destroy() never sees.
|
|
268
|
+
const pending = this.#createPinnedDispatcher(realHostname, realPort)
|
|
269
|
+
this.#pinnedDispatchers.set(key, pending)
|
|
270
|
+
return pending
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async #createPinnedDispatcher(realHostname, realPort) {
|
|
274
|
+
let undici
|
|
275
|
+
try {
|
|
276
|
+
undici = await import('undici')
|
|
277
|
+
} catch {
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const { Agent, buildConnector } = undici
|
|
282
|
+
if (typeof buildConnector !== 'function' || typeof Agent !== 'function') return null
|
|
283
|
+
|
|
284
|
+
const connector = buildConnector({})
|
|
285
|
+
const servername = this.#hostOverride.hostname
|
|
286
|
+
const connect = (opts, callback) => connector({
|
|
287
|
+
...opts,
|
|
288
|
+
hostname: realHostname,
|
|
289
|
+
host: realHostname,
|
|
290
|
+
port: realPort,
|
|
291
|
+
// SNI/cert validation follow the advertised host, not the dialed
|
|
292
|
+
// address — same contract as `curl --resolve`.
|
|
293
|
+
servername: opts.servername || servername
|
|
294
|
+
}, callback)
|
|
295
|
+
|
|
296
|
+
const agent = new Agent({ keepAliveTimeout: 4000, keepAliveMaxTimeout: 30000, connect })
|
|
297
|
+
// destroy() may have run while the import was in flight; don't leave an
|
|
298
|
+
// Agent behind that nobody will close.
|
|
299
|
+
if (this.#destroyed) {
|
|
300
|
+
try { await agent.destroy() } catch { /* nothing to release */ }
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
return agent
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Split a real backend URL into the URL we put on the wire (authority
|
|
308
|
+
* replaced by the configured hostHeader, so fetch emits it as Host) and the
|
|
309
|
+
* address the socket must actually dial.
|
|
310
|
+
*/
|
|
311
|
+
#applyHostOverride(url) {
|
|
312
|
+
const real = new URL(url)
|
|
313
|
+
const virtual = new URL(url)
|
|
314
|
+
virtual.hostname = this.#hostOverride.hostname
|
|
315
|
+
virtual.port = this.#hostOverride.port
|
|
316
|
+
return {
|
|
317
|
+
requestUrl: virtual.toString(),
|
|
318
|
+
// undici's own connector receives an already-unbracketed IPv6 literal;
|
|
319
|
+
// we bypass that step, so strip the brackets here.
|
|
320
|
+
realHostname: real.hostname.replace(/^\[(.*)\]$/, '$1'),
|
|
321
|
+
realPort: real.port || (real.protocol === 'https:' ? '443' : '80'),
|
|
322
|
+
realAuthority: real.host
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
49
326
|
async #executeRequest(url, method, body = null, requestTimeoutMillis = null) {
|
|
50
327
|
const effectiveTimeout = requestTimeoutMillis || this.#timeoutMillis
|
|
51
|
-
logger.log('HttpClient.request', { method, url, hasBody: !!body, timeout: effectiveTimeout })
|
|
52
|
-
|
|
328
|
+
logger.log('HttpClient.request', { method, url, hasBody: !!body, timeout: effectiveTimeout, host: this.#hostOverride ? this.#hostOverride.authority : undefined })
|
|
329
|
+
|
|
53
330
|
const controller = new AbortController()
|
|
54
331
|
const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout)
|
|
55
332
|
|
|
@@ -66,12 +343,30 @@ export class HttpClient {
|
|
|
66
343
|
headers
|
|
67
344
|
}
|
|
68
345
|
|
|
346
|
+
let requestUrl = url
|
|
347
|
+
if (this.#hostOverride) {
|
|
348
|
+
const target = this.#applyHostOverride(url)
|
|
349
|
+
const dispatcher = await this.#getPinnedDispatcher(target.realHostname, target.realPort)
|
|
350
|
+
if (!dispatcher) {
|
|
351
|
+
// Sending anyway would advertise Host: <address> and, at a proxy
|
|
352
|
+
// with a default cluster, silently write to the wrong tenant.
|
|
353
|
+
throw new Error(`hostHeader '${this.#hostOverride.authority}' cannot be honored here: no undici dispatcher available (browser environment, or this client was destroyed). Refusing to send with Host: ${target.realAuthority}, which would reach the wrong cluster.`)
|
|
354
|
+
}
|
|
355
|
+
options.dispatcher = dispatcher
|
|
356
|
+
requestUrl = target.requestUrl
|
|
357
|
+
} else {
|
|
358
|
+
const dispatcher = await this.#getDispatcher()
|
|
359
|
+
if (dispatcher) {
|
|
360
|
+
options.dispatcher = dispatcher
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
69
364
|
if (body) {
|
|
70
365
|
options.body = JSON.stringify(body)
|
|
71
366
|
}
|
|
72
367
|
|
|
73
|
-
const response = await fetch(
|
|
74
|
-
|
|
368
|
+
const response = await fetch(requestUrl, options)
|
|
369
|
+
|
|
75
370
|
logger.log('HttpClient.response', { method, url, status: response.status })
|
|
76
371
|
|
|
77
372
|
// Handle 204 No Content
|
|
@@ -84,17 +379,29 @@ export class HttpClient {
|
|
|
84
379
|
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
85
380
|
error.status = response.status
|
|
86
381
|
|
|
382
|
+
// Proxy error contract: 429 { error, code: 'rate_limited' | 'quota_exceeded' }
|
|
383
|
+
// with Retry-After (seconds); 403 { error, code: 'cluster_suspended' |
|
|
384
|
+
// 'storage_quota_exceeded' | 'feature_gated' | 'forbidden' }. `.code`
|
|
385
|
+
// is attached whenever the body has one so callers can branch without
|
|
386
|
+
// string-matching `.message`.
|
|
387
|
+
if (response.status === 429) {
|
|
388
|
+
const retryAfterHeader = response.headers.get('retry-after')
|
|
389
|
+
const retryAfterSeconds = retryAfterHeader != null ? Number(retryAfterHeader) : NaN
|
|
390
|
+
error.retryAfterSeconds = Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : null
|
|
391
|
+
}
|
|
392
|
+
|
|
87
393
|
try {
|
|
88
394
|
const text = await response.text()
|
|
89
395
|
if (text) {
|
|
90
396
|
const body = JSON.parse(text)
|
|
91
397
|
error.message = body.error || error.message
|
|
398
|
+
if (body.code) error.code = body.code
|
|
92
399
|
}
|
|
93
400
|
} catch (e) {
|
|
94
401
|
// Ignore JSON parse errors
|
|
95
402
|
}
|
|
96
403
|
|
|
97
|
-
logger.error('HttpClient.request', { method, url, status: error.status, error: error.message })
|
|
404
|
+
logger.error('HttpClient.request', { method, url, status: error.status, error: error.message, code: error.code })
|
|
98
405
|
throw error
|
|
99
406
|
}
|
|
100
407
|
|
|
@@ -124,6 +431,13 @@ export class HttpClient {
|
|
|
124
431
|
logger.error('HttpClient.request', { method, url, error: 'timeout', timeout: effectiveTimeout })
|
|
125
432
|
throw timeoutError
|
|
126
433
|
}
|
|
434
|
+
// undici reports every network-level failure as a bare TypeError "fetch
|
|
435
|
+
// failed" with the real reason (ECONNREFUSED, ECONNRESET, socket hang up,
|
|
436
|
+
// ...) hidden in error.cause — surface it so failures are diagnosable.
|
|
437
|
+
if (error.cause && error.message === 'fetch failed') {
|
|
438
|
+
const cause = error.cause.code || error.cause.message || String(error.cause)
|
|
439
|
+
error.message = `fetch failed (${cause})`
|
|
440
|
+
}
|
|
127
441
|
logger.error('HttpClient.request', { method, url, error: error.message })
|
|
128
442
|
throw error
|
|
129
443
|
} finally {
|
|
@@ -131,13 +445,13 @@ export class HttpClient {
|
|
|
131
445
|
}
|
|
132
446
|
}
|
|
133
447
|
|
|
134
|
-
async #requestWithRetry(method, path, body = null, requestTimeoutMillis = null) {
|
|
448
|
+
async #requestWithRetry(method, path, body = null, requestTimeoutMillis = null, retryKind = null) {
|
|
135
449
|
let lastError = null
|
|
136
450
|
|
|
137
451
|
for (let attempt = 0; attempt < this.#retryAttempts; attempt++) {
|
|
138
452
|
try {
|
|
139
453
|
const url = this.#getUrl() + path
|
|
140
|
-
return await this.#
|
|
454
|
+
return await this.#executeWithRetry429(url, method, body, requestTimeoutMillis, retryKind)
|
|
141
455
|
} catch (error) {
|
|
142
456
|
lastError = error
|
|
143
457
|
|
|
@@ -159,9 +473,9 @@ export class HttpClient {
|
|
|
159
473
|
throw lastError
|
|
160
474
|
}
|
|
161
475
|
|
|
162
|
-
async #requestWithFailover(method, path, body = null, requestTimeoutMillis = null, affinityKey = null) {
|
|
476
|
+
async #requestWithFailover(method, path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
|
|
163
477
|
if (!this.#loadBalancer || !this.#enableFailover) {
|
|
164
|
-
return this.#requestWithRetry(method, path, body, requestTimeoutMillis)
|
|
478
|
+
return this.#requestWithRetry(method, path, body, requestTimeoutMillis, retryKind)
|
|
165
479
|
}
|
|
166
480
|
|
|
167
481
|
const urls = this.#loadBalancer.getAllUrls()
|
|
@@ -181,23 +495,27 @@ export class HttpClient {
|
|
|
181
495
|
attemptedUrls.add(url)
|
|
182
496
|
|
|
183
497
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
498
|
+
// 429s are retried in place (same backend, backoff-paced) inside
|
|
499
|
+
// #executeWithRetry429 -- they are not a backend-health signal, so
|
|
500
|
+
// they must not trigger failover to a different server.
|
|
501
|
+
const result = await this.#executeWithRetry429(url + path, method, body, requestTimeoutMillis, retryKind)
|
|
502
|
+
|
|
186
503
|
// Mark backend as healthy on success
|
|
187
504
|
this.#loadBalancer.markHealthy(url)
|
|
188
|
-
|
|
505
|
+
|
|
189
506
|
return result
|
|
190
507
|
} catch (error) {
|
|
191
508
|
lastError = error
|
|
192
|
-
|
|
509
|
+
|
|
193
510
|
// Mark backend as unhealthy on failure (5xx or network errors)
|
|
194
511
|
if (!error.status || error.status >= 500) {
|
|
195
512
|
this.#loadBalancer.markUnhealthy(url)
|
|
196
513
|
}
|
|
197
|
-
|
|
198
|
-
logger.warn('HttpClient.failover', { url, method, path, error: error.message })
|
|
199
514
|
|
|
200
|
-
|
|
515
|
+
logger.warn('HttpClient.failover', { url, method, path, error: error.message, code: error.code })
|
|
516
|
+
|
|
517
|
+
// Don't retry on client errors (4xx) -- includes a 429 whose retry429
|
|
518
|
+
// policy has already been exhausted by #executeWithRetry429 above.
|
|
201
519
|
if (error.status && error.status >= 400 && error.status < 500) {
|
|
202
520
|
throw error
|
|
203
521
|
}
|
|
@@ -217,24 +535,56 @@ export class HttpClient {
|
|
|
217
535
|
return this.#baseUrl
|
|
218
536
|
}
|
|
219
537
|
|
|
220
|
-
|
|
221
|
-
|
|
538
|
+
// `retryKind`: pass 'pop' for long-poll (wait=true) pop requests to get the
|
|
539
|
+
// unbounded-with-backoff 429 policy; omit for everything else (push, admin
|
|
540
|
+
// calls, non-waiting pop), which get the bounded default (10 attempts).
|
|
541
|
+
async get(path, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
|
|
542
|
+
return this.#requestWithFailover('GET', path, null, requestTimeoutMillis, affinityKey, retryKind)
|
|
222
543
|
}
|
|
223
544
|
|
|
224
|
-
async post(path, body = null, requestTimeoutMillis = null, affinityKey = null) {
|
|
225
|
-
return this.#requestWithFailover('POST', path, body, requestTimeoutMillis, affinityKey)
|
|
545
|
+
async post(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
|
|
546
|
+
return this.#requestWithFailover('POST', path, body, requestTimeoutMillis, affinityKey, retryKind)
|
|
226
547
|
}
|
|
227
548
|
|
|
228
|
-
async put(path, body = null, requestTimeoutMillis = null, affinityKey = null) {
|
|
229
|
-
return this.#requestWithFailover('PUT', path, body, requestTimeoutMillis, affinityKey)
|
|
549
|
+
async put(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
|
|
550
|
+
return this.#requestWithFailover('PUT', path, body, requestTimeoutMillis, affinityKey, retryKind)
|
|
230
551
|
}
|
|
231
552
|
|
|
232
|
-
async delete(path, requestTimeoutMillis = null, affinityKey = null) {
|
|
233
|
-
return this.#requestWithFailover('DELETE', path, null, requestTimeoutMillis, affinityKey)
|
|
553
|
+
async delete(path, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
|
|
554
|
+
return this.#requestWithFailover('DELETE', path, null, requestTimeoutMillis, affinityKey, retryKind)
|
|
234
555
|
}
|
|
235
556
|
|
|
236
557
|
getLoadBalancer() {
|
|
237
558
|
return this.#loadBalancer
|
|
238
559
|
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Release the client's HTTP resources: force-close the per-client agent's
|
|
563
|
+
* keep-alive sockets so the Node event loop can drain. Idempotent; requests
|
|
564
|
+
* issued after destroy() fall back to the global fetch dispatcher.
|
|
565
|
+
*/
|
|
566
|
+
async destroy() {
|
|
567
|
+
if (this.#destroyed) return
|
|
568
|
+
this.#destroyed = true
|
|
569
|
+
const dispatcher = this.#dispatcher
|
|
570
|
+
// Subsequent (stray) requests use global fetch instead of a dead agent.
|
|
571
|
+
// With a hostHeader configured there is no such fallback (global fetch
|
|
572
|
+
// could not carry the Host) — those requests fail loudly instead.
|
|
573
|
+
this.#dispatcher = null
|
|
574
|
+
const pending = [...this.#pinnedDispatchers.values()]
|
|
575
|
+
this.#pinnedDispatchers.clear()
|
|
576
|
+
// Entries are promises (see #getPinnedDispatcher); settle them so an Agent
|
|
577
|
+
// created concurrently with close() is released too.
|
|
578
|
+
const pinned = await Promise.all(pending.map(p => Promise.resolve(p).catch(() => null)))
|
|
579
|
+
for (const agent of [dispatcher, ...pinned]) {
|
|
580
|
+
if (!agent) continue
|
|
581
|
+
try {
|
|
582
|
+
await agent.destroy()
|
|
583
|
+
logger.log('HttpClient.destroy', 'Agent destroyed')
|
|
584
|
+
} catch (error) {
|
|
585
|
+
logger.warn('HttpClient.destroy', { error: error.message })
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
239
589
|
}
|
|
240
590
|
|