queen-mq 0.2.23 → 0.3.1

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.
Files changed (35) hide show
  1. package/README.md +162 -136
  2. package/client-js/client-v2/LOGGING.md +240 -0
  3. package/client-js/client-v2/Queen.js +389 -0
  4. package/client-js/client-v2/README.md +1883 -0
  5. package/client-js/client-v2/buffer/BufferManager.js +215 -0
  6. package/client-js/client-v2/buffer/MessageBuffer.js +132 -0
  7. package/client-js/client-v2/builders/QueueBuilder.js +724 -0
  8. package/client-js/client-v2/builders/TransactionBuilder.js +110 -0
  9. package/client-js/client-v2/consumer/ConsumerManager.js +390 -0
  10. package/client-js/client-v2/http/HttpClient.js +215 -0
  11. package/client-js/client-v2/http/LoadBalancer.js +50 -0
  12. package/client-js/client-v2/index.js +7 -0
  13. package/client-js/client-v2/utils/defaults.js +54 -0
  14. package/client-js/client-v2/utils/logger.js +54 -0
  15. package/client-js/client-v2/utils/validation.js +31 -0
  16. package/client-js/test-v2/AI_TEST_SUMMARY.md +226 -0
  17. package/client-js/test-v2/GETTING_STARTED.md +154 -0
  18. package/client-js/test-v2/ai_buffering.js +194 -0
  19. package/client-js/test-v2/ai_error_handling.js +223 -0
  20. package/client-js/test-v2/ai_lease_renewal.js +206 -0
  21. package/client-js/test-v2/ai_mixed_scenarios.js +278 -0
  22. package/client-js/test-v2/ai_priority.js +169 -0
  23. package/client-js/test-v2/ai_resources.js +217 -0
  24. package/client-js/test-v2/ai_ttl_retention.js +170 -0
  25. package/client-js/test-v2/complete.js +59 -0
  26. package/client-js/test-v2/consume.js +655 -0
  27. package/client-js/test-v2/dlq.js +82 -0
  28. package/client-js/test-v2/load.js +177 -0
  29. package/client-js/test-v2/pop.js +114 -0
  30. package/client-js/test-v2/push.js +333 -0
  31. package/client-js/test-v2/queue.js +39 -0
  32. package/client-js/test-v2/run.js +187 -0
  33. package/client-js/test-v2/subscription.js +354 -0
  34. package/client-js/test-v2/transaction.js +443 -0
  35. package/package.json +2 -2
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Transaction builder for atomic operations
3
+ */
4
+
5
+ import * as logger from '../utils/logger.js'
6
+
7
+ export class TransactionBuilder {
8
+ #httpClient
9
+ #operations = []
10
+ #requiredLeases = []
11
+
12
+ constructor(httpClient) {
13
+ this.#httpClient = httpClient
14
+ }
15
+
16
+ ack(messages, status = 'completed') {
17
+ const msgs = Array.isArray(messages) ? messages : [messages]
18
+
19
+ logger.log('TransactionBuilder.ack', { count: msgs.length, status })
20
+
21
+ msgs.forEach(msg => {
22
+ const transactionId = typeof msg === 'string' ? msg : (msg.transactionId || msg.id)
23
+ const partitionId = typeof msg === 'object' ? msg.partitionId : null
24
+ const leaseId = typeof msg === 'object' ? msg.leaseId : null
25
+
26
+ if (!transactionId) {
27
+ throw new Error('Message must have transactionId or id property')
28
+ }
29
+
30
+ // CRITICAL: partitionId is now MANDATORY to prevent acking wrong message
31
+ if (!partitionId) {
32
+ throw new Error('Message must have partitionId property to ensure message uniqueness')
33
+ }
34
+
35
+ this.#operations.push({
36
+ type: 'ack',
37
+ transactionId,
38
+ partitionId,
39
+ status
40
+ })
41
+
42
+ if (leaseId) {
43
+ this.#requiredLeases.push(leaseId)
44
+ }
45
+ })
46
+
47
+ return this
48
+ }
49
+
50
+ queue(queueName) {
51
+ // Return a sub-builder for push operations
52
+ return {
53
+ push: (items) => {
54
+ const itemArray = Array.isArray(items) ? items : [items]
55
+
56
+ logger.log('TransactionBuilder.queue.push', { queue: queueName, count: itemArray.length })
57
+
58
+ this.#operations.push({
59
+ type: 'push',
60
+ items: itemArray.map(item => {
61
+ // Check if property exists, not just truthy (to support null values)
62
+ let payloadValue
63
+ if ('data' in item) {
64
+ payloadValue = item.data
65
+ } else if ('payload' in item) {
66
+ payloadValue = item.payload
67
+ } else {
68
+ payloadValue = item
69
+ }
70
+
71
+ return {
72
+ queue: queueName,
73
+ payload: payloadValue
74
+ }
75
+ })
76
+ })
77
+
78
+ return this
79
+ }
80
+ }
81
+ }
82
+
83
+ async commit() {
84
+ if (this.#operations.length === 0) {
85
+ logger.error('TransactionBuilder.commit', 'No operations to commit')
86
+ throw new Error('Transaction has no operations to commit')
87
+ }
88
+
89
+ logger.log('TransactionBuilder.commit', { operationCount: this.#operations.length, requiredLeases: this.#requiredLeases.length })
90
+
91
+ try {
92
+ const result = await this.#httpClient.post('/api/v1/transaction', {
93
+ operations: this.#operations,
94
+ requiredLeases: [...new Set(this.#requiredLeases)] // Unique leases
95
+ })
96
+
97
+ if (!result.success) {
98
+ logger.error('TransactionBuilder.commit', { error: result.error })
99
+ throw new Error(result.error || 'Transaction failed')
100
+ }
101
+
102
+ logger.log('TransactionBuilder.commit', { status: 'success' })
103
+ return result
104
+ } catch (error) {
105
+ logger.error('TransactionBuilder.commit', { error: error.message })
106
+ throw error
107
+ }
108
+ }
109
+ }
110
+
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Consumer manager for handling concurrent workers
3
+ */
4
+
5
+ import * as logger from '../utils/logger.js'
6
+
7
+ export class ConsumerManager {
8
+ #httpClient
9
+ #queen
10
+
11
+ constructor(httpClient, queen) {
12
+ this.#httpClient = httpClient
13
+ this.#queen = queen
14
+ }
15
+
16
+ async start(handler, options) {
17
+ const {
18
+ queue,
19
+ partition,
20
+ namespace,
21
+ task,
22
+ group,
23
+ concurrency,
24
+ batch,
25
+ limit,
26
+ idleMillis,
27
+ autoAck,
28
+ wait,
29
+ timeoutMillis,
30
+ renewLease,
31
+ renewLeaseIntervalMillis,
32
+ subscriptionMode,
33
+ subscriptionFrom,
34
+ each,
35
+ signal
36
+ } = options
37
+
38
+ logger.log('ConsumerManager.start', {
39
+ queue,
40
+ partition,
41
+ namespace,
42
+ task,
43
+ group,
44
+ concurrency,
45
+ batch,
46
+ limit,
47
+ autoAck,
48
+ wait,
49
+ each
50
+ })
51
+
52
+ // Build the path and params for pop requests
53
+ const path = this.#buildPath(queue, partition, namespace, task)
54
+ const baseParams = this.#buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck)
55
+
56
+ // Start workers
57
+ const workers = []
58
+ for (let i = 0; i < concurrency; i++) {
59
+ workers.push(this.#worker(i, handler, path, baseParams, {
60
+ batch,
61
+ limit,
62
+ idleMillis,
63
+ autoAck,
64
+ wait,
65
+ timeoutMillis,
66
+ renewLease,
67
+ renewLeaseIntervalMillis,
68
+ each,
69
+ signal,
70
+ group // Pass consumer group to workers
71
+ }))
72
+ }
73
+
74
+ logger.log('ConsumerManager.start', { status: 'workers-started', count: concurrency })
75
+
76
+ // Wait for all workers to complete
77
+ await Promise.all(workers)
78
+
79
+ logger.log('ConsumerManager.start', { status: 'completed' })
80
+ }
81
+
82
+ async #worker(workerId, handler, path, baseParams, options) {
83
+ const {
84
+ batch,
85
+ limit,
86
+ idleMillis,
87
+ autoAck,
88
+ wait,
89
+ timeoutMillis,
90
+ renewLease,
91
+ renewLeaseIntervalMillis,
92
+ each,
93
+ signal,
94
+ group
95
+ } = options
96
+
97
+ logger.log('ConsumerManager.worker', { workerId, status: 'started', limit, idleMillis })
98
+
99
+ let processedCount = 0
100
+ let lastMessageTime = idleMillis ? Date.now() : null
101
+
102
+ while (true) {
103
+ // Check abort signal
104
+ if (signal && signal.aborted) {
105
+ logger.log('ConsumerManager.worker', { workerId, status: 'aborted', processedCount })
106
+ break
107
+ }
108
+
109
+ // Check limit
110
+ if (limit && processedCount >= limit) {
111
+ logger.log('ConsumerManager.worker', { workerId, status: 'limit-reached', processedCount, limit })
112
+ break
113
+ }
114
+
115
+ // Check idle timeout
116
+ if (idleMillis && lastMessageTime) {
117
+ const idleTime = Date.now() - lastMessageTime
118
+ if (idleTime >= idleMillis) {
119
+ logger.log('ConsumerManager.worker', { workerId, status: 'idle-timeout', processedCount, idleTime })
120
+ break
121
+ }
122
+ }
123
+
124
+ try {
125
+ // Pop messages
126
+ const clientTimeout = wait ? timeoutMillis + 5000 : timeoutMillis
127
+ const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout)
128
+
129
+ // Handle empty response
130
+ if (!result || !result.messages || result.messages.length === 0) {
131
+ if (wait) {
132
+ continue // Long polling timeout, retry
133
+ } else {
134
+ // Short delay before retry
135
+ await new Promise(resolve => setTimeout(resolve, 100))
136
+ continue
137
+ }
138
+ }
139
+
140
+ const messages = result.messages.filter(msg => msg != null)
141
+
142
+ if (messages.length === 0) {
143
+ continue
144
+ }
145
+
146
+ logger.log('ConsumerManager.worker', { workerId, status: 'messages-received', count: messages.length })
147
+
148
+ // Enhance messages with trace() method
149
+ this.#enhanceMessagesWithTrace(messages, group)
150
+
151
+ // Update last message time
152
+ if (idleMillis) {
153
+ lastMessageTime = Date.now()
154
+ }
155
+
156
+ // Set up lease renewal if enabled
157
+ let renewalTimer = null
158
+ if (renewLease && renewLeaseIntervalMillis) {
159
+ renewalTimer = this.#setupLeaseRenewal(messages, renewLeaseIntervalMillis)
160
+ }
161
+
162
+ try {
163
+ // Process messages
164
+ if (each) {
165
+ // Process one at a time
166
+ for (const message of messages) {
167
+ if (signal && signal.aborted) break
168
+
169
+ await this.#processMessage(message, handler, autoAck, group)
170
+ processedCount++
171
+
172
+ if (limit && processedCount >= limit) break
173
+ }
174
+ } else {
175
+ // Process as batch
176
+ await this.#processBatch(messages, handler, autoAck, group)
177
+ processedCount += messages.length
178
+ }
179
+
180
+ logger.log('ConsumerManager.worker', { workerId, status: 'messages-processed', count: messages.length, total: processedCount })
181
+ } finally {
182
+ // Clear renewal timer
183
+ if (renewalTimer) {
184
+ clearInterval(renewalTimer)
185
+ }
186
+ }
187
+
188
+ } catch (error) {
189
+ // Check if this is a timeout error (expected for long polling)
190
+ const isTimeoutError = error.name === 'AbortError' ||
191
+ error.message?.includes('timeout')
192
+
193
+ if (isTimeoutError && wait) {
194
+ continue // Retry on timeout
195
+ }
196
+
197
+ // Check if network error
198
+ const isNetworkError = error.message?.includes('fetch failed') ||
199
+ error.message?.includes('ECONNREFUSED') ||
200
+ error.code === 'ECONNREFUSED'
201
+
202
+ if (isNetworkError) {
203
+ logger.warn('ConsumerManager.worker', { workerId, error: 'network', message: error.message })
204
+ console.warn(`Worker ${workerId}: Network error - ${error.message}`)
205
+ // Wait before retry
206
+ await new Promise(resolve => setTimeout(resolve, 1000))
207
+ continue
208
+ }
209
+
210
+ // Other errors - rethrow
211
+ logger.error('ConsumerManager.worker', { workerId, error: error.message })
212
+ throw error
213
+ }
214
+ }
215
+
216
+ logger.log('ConsumerManager.worker', { workerId, status: 'stopped', processedCount })
217
+ }
218
+
219
+ async #processMessage(message, handler, autoAck, group) {
220
+ try {
221
+ await handler(message)
222
+
223
+ // Auto-ack on success if enabled
224
+ if (autoAck) {
225
+ const context = group ? { group } : {}
226
+ await this.#queen.ack(message, true, context)
227
+ logger.log('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'acked' })
228
+ }
229
+ } catch (error) {
230
+ // Auto-nack on error if enabled
231
+ if (autoAck) {
232
+ const context = group ? { group } : {}
233
+ await this.#queen.ack(message, false, context)
234
+ logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message, status: 'nacked' })
235
+ // Don't rethrow when autoAck is enabled - NACK was already sent
236
+ // This allows the consumer to continue and retry
237
+ return
238
+ }
239
+ logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message })
240
+ throw error
241
+ }
242
+ }
243
+
244
+ async #processBatch(messages, handler, autoAck, group) {
245
+ try {
246
+ await handler(messages)
247
+
248
+ // Auto-ack on success if enabled
249
+ if (autoAck) {
250
+ const context = group ? { group } : {}
251
+ await this.#queen.ack(messages, true, context)
252
+ logger.log('ConsumerManager.processBatch', { count: messages.length, status: 'acked' })
253
+ }
254
+ } catch (error) {
255
+ // Auto-nack on error if enabled
256
+ if (autoAck) {
257
+ const context = group ? { group } : {}
258
+ await this.#queen.ack(messages, false, context)
259
+ logger.error('ConsumerManager.processBatch', { count: messages.length, error: error.message, status: 'nacked' })
260
+ // Don't rethrow when autoAck is enabled - NACK was already sent
261
+ // This allows the consumer to continue and retry
262
+ return
263
+ }
264
+ logger.error('ConsumerManager.processBatch', { count: messages.length, error: error.message })
265
+ throw error
266
+ }
267
+ }
268
+
269
+ #setupLeaseRenewal(messages, intervalMillis) {
270
+ const leaseIds = messages.map(m => m.leaseId).filter(id => id != null)
271
+
272
+ if (leaseIds.length === 0) return null
273
+
274
+ return setInterval(async () => {
275
+ try {
276
+ await this.#queen.renew(messages)
277
+ } catch (error) {
278
+ console.error('Lease renewal failed:', error)
279
+ }
280
+ }, intervalMillis)
281
+ }
282
+
283
+ #enhanceMessagesWithTrace(messages, group) {
284
+ const httpClient = this.#httpClient
285
+ const consumerGroup = group || '__QUEUE_MODE__'
286
+
287
+ for (const message of messages) {
288
+ /**
289
+ * Record a trace event for this message
290
+ * @param {object} traceConfig - Configuration object
291
+ * @param {string|string[]} [traceConfig.traceName] - Single name or array of names for categorization
292
+ * @param {string} [traceConfig.eventType='info'] - Event type (info, error, step, processing, etc.)
293
+ * @param {object} traceConfig.data - User data to store with the trace
294
+ * @returns {Promise<object>} Result with success status
295
+ *
296
+ * IMPORTANT: This method will NEVER crash - errors are logged but don't throw
297
+ *
298
+ * @example
299
+ * await msg.trace({
300
+ * traceName: ['tenant-acme', 'chat-room-123'],
301
+ * eventType: 'info',
302
+ * data: { text: 'Started processing', orderId: 123 }
303
+ * });
304
+ */
305
+ message.trace = async (traceConfig) => {
306
+ try {
307
+ // Validate required structure
308
+ if (typeof traceConfig !== 'object' || !traceConfig.data) {
309
+ logger.warn('ConsumerManager.trace', {
310
+ error: 'Invalid trace config: requires { data: {...} }',
311
+ transactionId: message.transactionId
312
+ })
313
+ return { success: false, error: 'Invalid trace config: requires { data: {...} }' }
314
+ }
315
+
316
+ // Normalize traceName to array
317
+ let traceNames = null
318
+ if (traceConfig.traceName) {
319
+ if (Array.isArray(traceConfig.traceName)) {
320
+ traceNames = traceConfig.traceName.filter(n => typeof n === 'string' && n.length > 0)
321
+ if (traceNames.length === 0) traceNames = null
322
+ } else if (typeof traceConfig.traceName === 'string') {
323
+ traceNames = [traceConfig.traceName]
324
+ }
325
+ }
326
+
327
+ const response = await httpClient.post('/api/v1/traces', {
328
+ transactionId: message.transactionId,
329
+ partitionId: message.partitionId,
330
+ consumerGroup: consumerGroup,
331
+ traceNames: traceNames,
332
+ eventType: traceConfig.eventType || 'info',
333
+ data: traceConfig.data
334
+ })
335
+
336
+ logger.log('ConsumerManager.trace', {
337
+ transactionId: message.transactionId,
338
+ success: true,
339
+ traceNames: traceNames
340
+ })
341
+ return { success: true, ...response }
342
+ } catch (error) {
343
+ // CRITICAL: NEVER CRASH - just log and return gracefully
344
+ logger.error('ConsumerManager.trace', {
345
+ transactionId: message.transactionId,
346
+ error: error.message,
347
+ phase: 'trace-failed'
348
+ })
349
+ console.warn(`[TRACE FAILED] ${message.transactionId}: ${error.message}`)
350
+
351
+ return { success: false, error: error.message }
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ #buildPath(queue, partition, namespace, task) {
358
+ if (queue) {
359
+ if (partition) {
360
+ return `/api/v1/pop/queue/${queue}/partition/${partition}`
361
+ }
362
+ return `/api/v1/pop/queue/${queue}`
363
+ }
364
+
365
+ if (namespace || task) {
366
+ return '/api/v1/pop'
367
+ }
368
+
369
+ throw new Error('Must specify queue, namespace, or task')
370
+ }
371
+
372
+ #buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck) {
373
+ const params = new URLSearchParams({
374
+ batch: batch.toString(),
375
+ wait: wait.toString(),
376
+ timeout: timeoutMillis.toString() // Server expects 'timeout', not 'timeoutMillis'
377
+ })
378
+
379
+ if (group) params.append('consumerGroup', group)
380
+ if (subscriptionMode) params.append('subscriptionMode', subscriptionMode)
381
+ if (subscriptionFrom) params.append('subscriptionFrom', subscriptionFrom)
382
+ if (namespace) params.append('namespace', namespace)
383
+ if (task) params.append('task', task)
384
+ // NEVER send autoAck for consume - client always manages acking
385
+ // autoAck is only for pop() where server auto-acks immediately
386
+
387
+ return params
388
+ }
389
+ }
390
+
@@ -0,0 +1,215 @@
1
+ /**
2
+ * HTTP client with retry, load balancing, and failover support
3
+ */
4
+
5
+ import * as logger from '../utils/logger.js'
6
+
7
+ export class HttpClient {
8
+ #baseUrl
9
+ #loadBalancer
10
+ #timeoutMillis
11
+ #retryAttempts
12
+ #retryDelayMillis
13
+ #enableFailover
14
+
15
+ constructor(options = {}) {
16
+ const {
17
+ baseUrl = null,
18
+ loadBalancer = null,
19
+ timeoutMillis = 30000,
20
+ retryAttempts = 3,
21
+ retryDelayMillis = 1000,
22
+ enableFailover = true
23
+ } = options
24
+
25
+ this.#baseUrl = baseUrl
26
+ this.#loadBalancer = loadBalancer
27
+ this.#timeoutMillis = timeoutMillis
28
+ this.#retryAttempts = retryAttempts
29
+ this.#retryDelayMillis = retryDelayMillis
30
+ this.#enableFailover = enableFailover
31
+
32
+ logger.log('HttpClient.constructor', {
33
+ hasLoadBalancer: !!loadBalancer,
34
+ baseUrl: baseUrl || 'load-balanced',
35
+ timeoutMillis,
36
+ retryAttempts,
37
+ enableFailover
38
+ })
39
+ }
40
+
41
+ async #executeRequest(url, method, body = null, requestTimeoutMillis = null) {
42
+ const effectiveTimeout = requestTimeoutMillis || this.#timeoutMillis
43
+ logger.log('HttpClient.request', { method, url, hasBody: !!body, timeout: effectiveTimeout })
44
+
45
+ const controller = new AbortController()
46
+ const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout)
47
+
48
+ try {
49
+ const options = {
50
+ method,
51
+ signal: controller.signal,
52
+ headers: { 'Content-Type': 'application/json' }
53
+ }
54
+
55
+ if (body) {
56
+ options.body = JSON.stringify(body)
57
+ }
58
+
59
+ const response = await fetch(url, options)
60
+
61
+ logger.log('HttpClient.response', { method, url, status: response.status })
62
+
63
+ // Handle 204 No Content
64
+ if (response.status === 204) {
65
+ return null
66
+ }
67
+
68
+ // Handle errors
69
+ if (!response.ok) {
70
+ const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
71
+ error.status = response.status
72
+
73
+ try {
74
+ const text = await response.text()
75
+ if (text) {
76
+ const body = JSON.parse(text)
77
+ error.message = body.error || error.message
78
+ }
79
+ } catch (e) {
80
+ // Ignore JSON parse errors
81
+ }
82
+
83
+ logger.error('HttpClient.request', { method, url, status: error.status, error: error.message })
84
+ throw error
85
+ }
86
+
87
+ // Parse successful response
88
+ const contentType = response.headers.get('content-type')
89
+ const contentLength = response.headers.get('content-length')
90
+
91
+ if (!contentType || !contentType.includes('application/json') || contentLength === '0') {
92
+ const text = await response.text()
93
+ if (!text || text.length === 0) {
94
+ return null
95
+ }
96
+ try {
97
+ return JSON.parse(text)
98
+ } catch (e) {
99
+ return null
100
+ }
101
+ }
102
+
103
+ return response.json()
104
+
105
+ } catch (error) {
106
+ if (error.name === 'AbortError') {
107
+ const timeoutError = new Error(`Request timeout after ${effectiveTimeout}ms`)
108
+ timeoutError.name = 'AbortError'
109
+ timeoutError.timeout = effectiveTimeout
110
+ logger.error('HttpClient.request', { method, url, error: 'timeout', timeout: effectiveTimeout })
111
+ throw timeoutError
112
+ }
113
+ logger.error('HttpClient.request', { method, url, error: error.message })
114
+ throw error
115
+ } finally {
116
+ clearTimeout(timeoutId)
117
+ }
118
+ }
119
+
120
+ async #requestWithRetry(method, path, body = null, requestTimeoutMillis = null) {
121
+ let lastError = null
122
+
123
+ for (let attempt = 0; attempt < this.#retryAttempts; attempt++) {
124
+ try {
125
+ const url = this.#getUrl() + path
126
+ return await this.#executeRequest(url, method, body, requestTimeoutMillis)
127
+ } catch (error) {
128
+ lastError = error
129
+
130
+ // Don't retry on client errors (4xx)
131
+ if (error.status && error.status >= 400 && error.status < 500) {
132
+ throw error
133
+ }
134
+
135
+ // Wait before retry (except on last attempt)
136
+ if (attempt < this.#retryAttempts - 1) {
137
+ const delay = this.#retryDelayMillis * Math.pow(2, attempt)
138
+ logger.warn('HttpClient.retry', { method, path, attempt: attempt + 1, delay, error: error.message })
139
+ await new Promise(resolve => setTimeout(resolve, delay))
140
+ }
141
+ }
142
+ }
143
+
144
+ logger.error('HttpClient.retry', { method, path, error: 'Max retries exceeded', attempts: this.#retryAttempts })
145
+ throw lastError
146
+ }
147
+
148
+ async #requestWithFailover(method, path, body = null, requestTimeoutMillis = null) {
149
+ if (!this.#loadBalancer || !this.#enableFailover) {
150
+ return this.#requestWithRetry(method, path, body, requestTimeoutMillis)
151
+ }
152
+
153
+ const urls = this.#loadBalancer.getAllUrls()
154
+ const attemptedUrls = new Set()
155
+ let lastError = null
156
+
157
+ logger.log('HttpClient.failover', { method, path, totalServers: urls.length })
158
+
159
+ for (let i = 0; i < urls.length; i++) {
160
+ const url = this.#loadBalancer.getNextUrl()
161
+
162
+ if (attemptedUrls.has(url)) {
163
+ continue
164
+ }
165
+
166
+ attemptedUrls.add(url)
167
+
168
+ try {
169
+ return await this.#executeRequest(url + path, method, body, requestTimeoutMillis)
170
+ } catch (error) {
171
+ lastError = error
172
+ logger.warn('HttpClient.failover', { url, method, path, error: error.message })
173
+ console.warn(`Request failed for ${url}: ${method} ${path} - ${error.message}`)
174
+
175
+ // Don't retry on client errors (4xx)
176
+ if (error.status && error.status >= 400 && error.status < 500) {
177
+ throw error
178
+ }
179
+
180
+ // Continue to next server for server errors or network issues
181
+ }
182
+ }
183
+
184
+ logger.error('HttpClient.failover', { method, path, error: 'All servers failed', attempted: attemptedUrls.size })
185
+ throw lastError || new Error('All servers failed')
186
+ }
187
+
188
+ #getUrl() {
189
+ if (this.#loadBalancer) {
190
+ return this.#loadBalancer.getNextUrl()
191
+ }
192
+ return this.#baseUrl
193
+ }
194
+
195
+ async get(path, requestTimeoutMillis = null) {
196
+ return this.#requestWithFailover('GET', path, null, requestTimeoutMillis)
197
+ }
198
+
199
+ async post(path, body = null, requestTimeoutMillis = null) {
200
+ return this.#requestWithFailover('POST', path, body, requestTimeoutMillis)
201
+ }
202
+
203
+ async put(path, body = null, requestTimeoutMillis = null) {
204
+ return this.#requestWithFailover('PUT', path, body, requestTimeoutMillis)
205
+ }
206
+
207
+ async delete(path, requestTimeoutMillis = null) {
208
+ return this.#requestWithFailover('DELETE', path, null, requestTimeoutMillis)
209
+ }
210
+
211
+ getLoadBalancer() {
212
+ return this.#loadBalancer
213
+ }
214
+ }
215
+