@vereda/http 1.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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +604 -0
  3. package/dist/adapters/zod.d.ts +14 -0
  4. package/dist/adapters/zod.d.ts.map +1 -0
  5. package/dist/adapters/zod.js +14 -0
  6. package/dist/adapters/zod.js.map +1 -0
  7. package/dist/core/backoff.d.ts +9 -0
  8. package/dist/core/backoff.d.ts.map +1 -0
  9. package/dist/core/backoff.js +23 -0
  10. package/dist/core/backoff.js.map +1 -0
  11. package/dist/core/client.d.ts +98 -0
  12. package/dist/core/client.d.ts.map +1 -0
  13. package/dist/core/client.js +781 -0
  14. package/dist/core/client.js.map +1 -0
  15. package/dist/core/errors.d.ts +87 -0
  16. package/dist/core/errors.d.ts.map +1 -0
  17. package/dist/core/errors.js +140 -0
  18. package/dist/core/errors.js.map +1 -0
  19. package/dist/core/index.d.ts +15 -0
  20. package/dist/core/index.d.ts.map +1 -0
  21. package/dist/core/index.js +10 -0
  22. package/dist/core/index.js.map +1 -0
  23. package/dist/core/listeners.d.ts +14 -0
  24. package/dist/core/listeners.d.ts.map +1 -0
  25. package/dist/core/listeners.js +27 -0
  26. package/dist/core/listeners.js.map +1 -0
  27. package/dist/core/metrics.d.ts +33 -0
  28. package/dist/core/metrics.d.ts.map +1 -0
  29. package/dist/core/metrics.js +24 -0
  30. package/dist/core/metrics.js.map +1 -0
  31. package/dist/core/nanoid.d.ts +2 -0
  32. package/dist/core/nanoid.d.ts.map +1 -0
  33. package/dist/core/nanoid.js +11 -0
  34. package/dist/core/nanoid.js.map +1 -0
  35. package/dist/core/redact.d.ts +12 -0
  36. package/dist/core/redact.d.ts.map +1 -0
  37. package/dist/core/redact.js +42 -0
  38. package/dist/core/redact.js.map +1 -0
  39. package/dist/core/types.d.ts +261 -0
  40. package/dist/core/types.d.ts.map +1 -0
  41. package/dist/core/types.js +41 -0
  42. package/dist/core/types.js.map +1 -0
  43. package/dist/core/validate.d.ts +19 -0
  44. package/dist/core/validate.d.ts.map +1 -0
  45. package/dist/core/validate.js +135 -0
  46. package/dist/core/validate.js.map +1 -0
  47. package/dist/middleware/index.d.ts +26 -0
  48. package/dist/middleware/index.d.ts.map +1 -0
  49. package/dist/middleware/index.js +55 -0
  50. package/dist/middleware/index.js.map +1 -0
  51. package/dist/queue/bulkhead.d.ts +63 -0
  52. package/dist/queue/bulkhead.d.ts.map +1 -0
  53. package/dist/queue/bulkhead.js +192 -0
  54. package/dist/queue/bulkhead.js.map +1 -0
  55. package/dist/queue/circuit-breaker.d.ts +81 -0
  56. package/dist/queue/circuit-breaker.d.ts.map +1 -0
  57. package/dist/queue/circuit-breaker.js +283 -0
  58. package/dist/queue/circuit-breaker.js.map +1 -0
  59. package/dist/queue/executor.d.ts +67 -0
  60. package/dist/queue/executor.d.ts.map +1 -0
  61. package/dist/queue/executor.js +273 -0
  62. package/dist/queue/executor.js.map +1 -0
  63. package/dist/queue/policy.d.ts +26 -0
  64. package/dist/queue/policy.d.ts.map +1 -0
  65. package/dist/queue/policy.js +37 -0
  66. package/dist/queue/policy.js.map +1 -0
  67. package/dist/queue/retry.d.ts +58 -0
  68. package/dist/queue/retry.d.ts.map +1 -0
  69. package/dist/queue/retry.js +259 -0
  70. package/dist/queue/retry.js.map +1 -0
  71. package/dist/queue/semaphore.d.ts +32 -0
  72. package/dist/queue/semaphore.d.ts.map +1 -0
  73. package/dist/queue/semaphore.js +83 -0
  74. package/dist/queue/semaphore.js.map +1 -0
  75. package/dist/ticket/ticket.d.ts +77 -0
  76. package/dist/ticket/ticket.d.ts.map +1 -0
  77. package/dist/ticket/ticket.js +186 -0
  78. package/dist/ticket/ticket.js.map +1 -0
  79. package/package.json +85 -0
  80. package/src/adapters/zod.ts +16 -0
  81. package/src/core/backoff.ts +26 -0
  82. package/src/core/client.ts +1048 -0
  83. package/src/core/errors.ts +194 -0
  84. package/src/core/index.ts +56 -0
  85. package/src/core/listeners.ts +28 -0
  86. package/src/core/metrics.ts +42 -0
  87. package/src/core/nanoid.ts +11 -0
  88. package/src/core/redact.ts +46 -0
  89. package/src/core/types.ts +306 -0
  90. package/src/core/validate.ts +163 -0
  91. package/src/middleware/index.ts +63 -0
  92. package/src/queue/bulkhead.ts +243 -0
  93. package/src/queue/circuit-breaker.ts +373 -0
  94. package/src/queue/executor.ts +355 -0
  95. package/src/queue/policy.ts +49 -0
  96. package/src/queue/retry.ts +380 -0
  97. package/src/queue/semaphore.ts +91 -0
  98. package/src/ticket/ticket.ts +246 -0
@@ -0,0 +1,781 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { BulkheadRegistry, DEFAULT_PARTITION_TTL_MS } from "../queue/bulkhead.js";
3
+ import { CircuitBreakerRegistry } from "../queue/circuit-breaker.js";
4
+ import { executeRequest } from "../queue/executor.js";
5
+ import { shouldRetry } from "../queue/policy.js";
6
+ import { runRetryLoop } from "../queue/retry.js";
7
+ import { Semaphore } from "../queue/semaphore.js";
8
+ import { createTicket } from "../ticket/ticket.js";
9
+ import { CancelledError, CircuitOpenError, ConfigurationError, DeadlineExceededError, isAppError, NetworkError, NO_TIMEOUT_CONFIGURED, TimeoutError, } from "./errors.js";
10
+ import { emitIsolated, reportCallbackError } from "./listeners.js";
11
+ import { METRICS } from "./metrics.js";
12
+ import { nanoid } from "./nanoid.js";
13
+ import { redactUrl } from "./redact.js";
14
+ import { DEFAULT_GLOBAL_CONCURRENCY, DEFAULT_GLOBAL_QUEUE_SIZE, DEFAULT_MAX_RETRIES, isBoundedMs } from "./types.js";
15
+ import { validateConfig, validateRequestBody, validateRequestOptions } from "./validate.js";
16
+ export class HttpClient {
17
+ config;
18
+ emitter = new EventEmitter();
19
+ middlewares = [];
20
+ bulkheads;
21
+ circuitBreakers;
22
+ partitionConfigs;
23
+ logger;
24
+ metrics;
25
+ redactQuery;
26
+ customFetch;
27
+ _closed = false;
28
+ _closing;
29
+ _inflightTickets = new Set();
30
+ constructor(config) {
31
+ this.config = config;
32
+ this.logger = config.logger;
33
+ this.metrics = config.metrics;
34
+ this.redactQuery = config.redactQuery !== false;
35
+ this.customFetch = config.fetch;
36
+ this.partitionConfigs = config.partitions ?? {};
37
+ const semaphore = new Semaphore(config.concurrency ?? DEFAULT_GLOBAL_CONCURRENCY, config.maxQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE);
38
+ this.bulkheads = new BulkheadRegistry({}, this.partitionConfigs, DEFAULT_PARTITION_TTL_MS, semaphore);
39
+ this.circuitBreakers = new CircuitBreakerRegistry(config.circuitBreaker ?? {}, this.partitionConfigs, DEFAULT_PARTITION_TTL_MS, (partition, state) => {
40
+ if (state === "open") {
41
+ this.emit("circuitOpen", { partition });
42
+ }
43
+ else {
44
+ this.emit("circuitClose", { partition });
45
+ }
46
+ });
47
+ }
48
+ static create(config) {
49
+ validateConfig(config);
50
+ return new HttpClient(config);
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // Middleware
54
+ // ---------------------------------------------------------------------------
55
+ use(middleware) {
56
+ this.middlewares.push(middleware);
57
+ return this;
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // Lifecycle events
61
+ // ---------------------------------------------------------------------------
62
+ on(event, listener) {
63
+ this.emitter.on(event, listener);
64
+ return this;
65
+ }
66
+ off(event, listener) {
67
+ this.emitter.off(event, listener);
68
+ return this;
69
+ }
70
+ request(url, options = {}) {
71
+ return this.send(url, options);
72
+ }
73
+ send(url, options) {
74
+ if (this._closed) {
75
+ throw new ConfigurationError("client closed");
76
+ }
77
+ const startTime = Date.now();
78
+ const ticketId = nanoid();
79
+ const { ticket, controller } = createTicket(ticketId);
80
+ // Wire external cancellation: aborting options.signal cancels the ticket.
81
+ // The listener is removed when the ticket reaches a terminal state to
82
+ // prevent a leak on the caller's signal (#7).
83
+ let externalAbortListener;
84
+ if (options.signal) {
85
+ if (options.signal.aborted) {
86
+ ticket.cancel();
87
+ }
88
+ else {
89
+ externalAbortListener = () => ticket.cancel();
90
+ options.signal.addEventListener("abort", externalAbortListener, {
91
+ once: true,
92
+ });
93
+ }
94
+ }
95
+ const cleanupExternalSignal = () => {
96
+ if (externalAbortListener && options.signal) {
97
+ options.signal.removeEventListener("abort", externalAbortListener);
98
+ externalAbortListener = undefined;
99
+ }
100
+ };
101
+ // Total deadline: a single unref'd timer that aborts the ticket signal
102
+ // on expiry, cancelling in-flight attempts and breaking sleep (#R3).
103
+ // Merged against the same partition _fireFirstAttempt resolves — the
104
+ // host, unless named explicitly — or a host partition's totalMs is
105
+ // silently ignored (B4).
106
+ const timeoutConfig = this.mergeTimeout(options, this.tryResolvePartition(url, options));
107
+ let deadlineTimer;
108
+ const cleanupDeadline = () => {
109
+ if (deadlineTimer !== undefined) {
110
+ clearTimeout(deadlineTimer);
111
+ deadlineTimer = undefined;
112
+ }
113
+ };
114
+ if (isBoundedMs(timeoutConfig.totalMs)) {
115
+ deadlineTimer = setTimeout(() => {
116
+ controller.abortSignal();
117
+ }, timeoutConfig.totalMs);
118
+ deadlineTimer.unref();
119
+ }
120
+ // Track this ticket for graceful shutdown. Store the entry so cleanup
121
+ // can remove the same reference (Set.delete uses reference equality).
122
+ const entry = {
123
+ ticket: ticket,
124
+ cleanup: () => { },
125
+ };
126
+ // Combined cleanup: external signal + deadline timer + inflight tracking
127
+ entry.cleanup = () => {
128
+ cleanupExternalSignal();
129
+ cleanupDeadline();
130
+ this._inflightTickets.delete(entry);
131
+ };
132
+ this._inflightTickets.add(entry);
133
+ // Fire and forget — first attempt runs immediately; queued if slow/failed
134
+ this._fireFirstAttempt(url, options, ticket, controller, entry.cleanup, startTime).catch((err) => {
135
+ // A pre-typed RequestError (e.g. QueueFullError from the global
136
+ // semaphore acquire in _fireFirstAttempt) is an expected, well-typed
137
+ // error — preserve it as-is instead of demoting it to a generic
138
+ // NetworkError. Anything else is a genuinely unexpected throw. Only
139
+ // emit/markDone if the ticket isn't already resolved — e.g. the user
140
+ // called cancel() while bulkhead.run()/semaphore.acquire() was still
141
+ // pending, which settles the ticket via its own "cancelled" event
142
+ // before this rejection arrives; emitting "failure" too would violate
143
+ // "exactly one of success/failure/cancelled per ticket".
144
+ const error = isAppError(err)
145
+ ? err
146
+ : new NetworkError(err instanceof Error ? err.message : "Unexpected error", { cause: err });
147
+ if (!ticket.isSettled) {
148
+ const durationMs = Date.now() - startTime;
149
+ this.emit("failure", {
150
+ ticketId: ticket.id,
151
+ url: this.logUrl(url),
152
+ partition: this.tryResolvePartition(url, options),
153
+ attempts: 1,
154
+ durationMs,
155
+ queuedMs: 0,
156
+ error,
157
+ });
158
+ controller.markDone({ success: false, error });
159
+ }
160
+ entry.cleanup();
161
+ });
162
+ return ticket;
163
+ }
164
+ async _fireFirstAttempt(url, options, ticket, controller, cleanup, startTime) {
165
+ // Resolve URL and partition inside the async path so relative URLs
166
+ // without a baseUrl surface as a ticket ConfigurationError instead of
167
+ // throwing. It's a caller mistake, not a network fault, so it is never
168
+ // retried.
169
+ let fullUrl;
170
+ let partitionName;
171
+ try {
172
+ fullUrl = this.resolveUrl(url);
173
+ partitionName = options.partition ?? new URL(fullUrl).host;
174
+ }
175
+ catch (err) {
176
+ const error = new ConfigurationError(this.config.baseUrl
177
+ ? `url ${this.logUrl(url)} cannot be resolved against baseUrl`
178
+ : `url ${this.logUrl(url)} must be absolute when no baseUrl is set`, { cause: err });
179
+ const durationMs = Date.now() - startTime;
180
+ this.emit("failure", {
181
+ ticketId: ticket.id,
182
+ url: this.logUrl(url),
183
+ partition: undefined,
184
+ attempts: 1,
185
+ durationMs,
186
+ queuedMs: 0,
187
+ error,
188
+ });
189
+ controller.markDone({ success: false, error });
190
+ cleanup();
191
+ return;
192
+ }
193
+ // Validate the body early in the async path so an unusable body (a raw
194
+ // ReadableStream) surfaces as a ticket ConfigurationError, not a throw.
195
+ try {
196
+ validateRequestBody(options.body);
197
+ }
198
+ catch (err) {
199
+ const error = err;
200
+ const durationMs = Date.now() - startTime;
201
+ this.emit("failure", {
202
+ ticketId: ticket.id,
203
+ url: this.logUrl(url),
204
+ partition: partitionName,
205
+ attempts: 1,
206
+ durationMs,
207
+ queuedMs: 0,
208
+ error,
209
+ });
210
+ controller.markDone({ success: false, error });
211
+ cleanup();
212
+ return;
213
+ }
214
+ // Validate the request's own timeout/retry options before merging them
215
+ // onto partition/client defaults, so an invalid raw value (e.g.
216
+ // `timeout: { attemptMs: -5 }`) surfaces as a ticket ConfigurationError,
217
+ // not a silent broken timer or a throw.
218
+ try {
219
+ validateRequestOptions(options);
220
+ }
221
+ catch (err) {
222
+ const error = err;
223
+ const durationMs = Date.now() - startTime;
224
+ this.emit("failure", {
225
+ ticketId: ticket.id,
226
+ url: this.logUrl(url),
227
+ partition: partitionName,
228
+ attempts: 1,
229
+ durationMs,
230
+ queuedMs: 0,
231
+ error,
232
+ });
233
+ controller.markDone({ success: false, error });
234
+ cleanup();
235
+ return;
236
+ }
237
+ const timeoutConfig = this.mergeTimeout(options, partitionName);
238
+ const retryConfig = this.mergeRetry(options, partitionName);
239
+ const bulkhead = this.bulkheads.get(partitionName);
240
+ const displayUrl = this.logUrl(fullUrl);
241
+ this.emit("request", {
242
+ ticketId: ticket.id,
243
+ url: displayUrl,
244
+ method: options.method ?? "GET",
245
+ partition: partitionName,
246
+ });
247
+ this.logger?.info("Request initiated", {
248
+ ticketId: ticket.id,
249
+ url: displayUrl,
250
+ method: options.method ?? "GET",
251
+ partition: partitionName,
252
+ });
253
+ const breaker = this.circuitBreakers.get(partitionName);
254
+ const permit = breaker.tryAcquire();
255
+ if (!permit) {
256
+ const error = new CircuitOpenError(partitionName);
257
+ const durationMs = Date.now() - startTime;
258
+ this.emit("failure", {
259
+ ticketId: ticket.id,
260
+ url: displayUrl,
261
+ partition: partitionName,
262
+ attempts: 1,
263
+ durationMs,
264
+ queuedMs: 0,
265
+ error,
266
+ });
267
+ controller.markDone({ success: false, error });
268
+ cleanup();
269
+ return;
270
+ }
271
+ // The permit must be settled on every exit — including a QueueFullError
272
+ // thrown by the semaphore below, and cancellation/deadline, which record
273
+ // no outcome — or a half-open trial slot leaks and wedges the breaker (B1).
274
+ try {
275
+ // The global semaphore limits total concurrent executions across all
276
+ // partitions. It is acquired for every attempt (including the first),
277
+ // while the per-partition bulkhead slot only applies to retries (D4).
278
+ const semaphore = this.bulkheads.getSemaphore();
279
+ // Absolute deadline for a handed-off Response body's read window
280
+ // (ExecuteRequest.deadlineAt) — undefined when totalMs isn't bounded.
281
+ const deadlineAt = isBoundedMs(timeoutConfig.totalMs) ? startTime + timeoutConfig.totalMs : undefined;
282
+ const execute = () => executeRequest({
283
+ url: fullUrl,
284
+ options,
285
+ timeoutConfig,
286
+ retryConfig,
287
+ deadlineAt,
288
+ displayUrl,
289
+ signal: ticket.signal,
290
+ attempt: 0,
291
+ ticketId: ticket.id,
292
+ partition: partitionName,
293
+ fetch: this.customFetch,
294
+ }, this.middlewares);
295
+ // When partition.limitFirstAttempts is enabled (R6), the first attempt
296
+ // also goes through the per-partition bulkhead. Otherwise it bypasses
297
+ // the partition slot entirely (D4). The global semaphore is always
298
+ // acquired for every attempt — bulkhead.run() acquires it internally
299
+ // when passed, so the task itself must not acquire it a second time.
300
+ const usePartitionBulkhead = bulkhead.limitFirstAttempts;
301
+ let queuedMs = 0;
302
+ let result;
303
+ // Queue waits are abort-aware (B9): a ticket cancelled — or past its
304
+ // deadline — while waiting leaves the queue immediately and lands in
305
+ // the "cancelled" case below, which tells the two apart.
306
+ const cancelledWhileQueued = (err) => {
307
+ if (err instanceof CancelledError)
308
+ return { kind: "cancelled" };
309
+ throw err;
310
+ };
311
+ if (usePartitionBulkhead) {
312
+ result = await bulkhead
313
+ .run(execute, semaphore, (ms) => {
314
+ queuedMs = ms;
315
+ }, ticket.signal)
316
+ .catch(cancelledWhileQueued);
317
+ }
318
+ else if (semaphore) {
319
+ const enqueuedAt = Date.now();
320
+ result = await semaphore.acquire(ticket.signal).then((release) => {
321
+ queuedMs = Date.now() - enqueuedAt;
322
+ return execute().finally(release);
323
+ }, (err) => {
324
+ queuedMs = Date.now() - enqueuedAt;
325
+ return cancelledWhileQueued(err);
326
+ });
327
+ }
328
+ else {
329
+ result = await execute();
330
+ }
331
+ switch (result.kind) {
332
+ case "success": {
333
+ permit.success();
334
+ const durationMs = Date.now() - startTime;
335
+ const statusCode = result.result.success ? result.result.raw.status : 0;
336
+ this.emit("success", {
337
+ ticketId: ticket.id,
338
+ url: displayUrl,
339
+ partition: partitionName,
340
+ attempts: 1,
341
+ durationMs,
342
+ queuedMs,
343
+ statusCode,
344
+ });
345
+ this.logger?.info("Request succeeded", {
346
+ ticketId: ticket.id,
347
+ url: displayUrl,
348
+ });
349
+ controller.markDone(result.result);
350
+ cleanup();
351
+ return;
352
+ }
353
+ case "cancelled": {
354
+ const durationMs = Date.now() - startTime;
355
+ // The deadline timer aborts the ticket signal without cancel(), so
356
+ // !isCancelled means the deadline fired: that's a failure, and the
357
+ // event must agree with the result (B7), as in the retry loop.
358
+ if (!ticket.isCancelled && isBoundedMs(timeoutConfig.totalMs)) {
359
+ const error = new DeadlineExceededError(displayUrl, timeoutConfig.totalMs);
360
+ this.emit("failure", {
361
+ ticketId: ticket.id,
362
+ url: displayUrl,
363
+ partition: partitionName,
364
+ attempts: 1,
365
+ durationMs,
366
+ queuedMs,
367
+ error,
368
+ });
369
+ controller.markDone({ success: false, error });
370
+ }
371
+ else {
372
+ this.emit("cancelled", {
373
+ ticketId: ticket.id,
374
+ url: displayUrl,
375
+ partition: partitionName,
376
+ attempts: 1,
377
+ durationMs,
378
+ queuedMs,
379
+ });
380
+ controller.markDone({ success: false, error: new CancelledError() });
381
+ }
382
+ cleanup();
383
+ return;
384
+ }
385
+ case "error": {
386
+ // Record the raw attempt outcome against the circuit breaker
387
+ // regardless of what the retry policy decides to do with it.
388
+ permit.failure(result.error);
389
+ // maxRetries=0: no retries configured, surface the raw error immediately
390
+ // without entering the bulkhead (which would waste a slot for no work),
391
+ // and without consulting retryWhen — there is nothing left to decide
392
+ // for an attempt that couldn't be retried anyway.
393
+ const effectiveMaxRetries = retryConfig.maxRetries ?? DEFAULT_MAX_RETRIES;
394
+ if (effectiveMaxRetries === 0) {
395
+ const durationMs = Date.now() - startTime;
396
+ this.emit("failure", {
397
+ ticketId: ticket.id,
398
+ url: displayUrl,
399
+ partition: partitionName,
400
+ attempts: 1,
401
+ durationMs,
402
+ queuedMs,
403
+ error: result.error,
404
+ });
405
+ controller.markDone({
406
+ success: false,
407
+ error: result.error,
408
+ });
409
+ cleanup();
410
+ return;
411
+ }
412
+ // Apply the unified retry gate (default policy + retryWhen). Errors
413
+ // that fail it (e.g. ValidationError, HttpError) resolve immediately.
414
+ if (this.vetoed(retryConfig, result.error, 0, options, ticket, controller, displayUrl, partitionName, startTime, queuedMs)) {
415
+ cleanup();
416
+ return;
417
+ }
418
+ controller.markQueued();
419
+ this._scheduleInBulkhead(ticket, controller, fullUrl, displayUrl, options, timeoutConfig, retryConfig, partitionName, bulkhead, breaker, result.error, cleanup, startTime, queuedMs);
420
+ return;
421
+ }
422
+ case "timeout": {
423
+ const error = new TimeoutError(displayUrl, timeoutConfig.attemptMs ?? NO_TIMEOUT_CONFIGURED);
424
+ // Record the raw attempt outcome against the circuit breaker
425
+ // regardless of what the retry policy decides to do with it.
426
+ permit.failure(error);
427
+ // maxRetries=0: surface timeout immediately without entering the
428
+ // bulkhead, and without consulting retryWhen — see the "error"
429
+ // case above for why this must come before the retry gate.
430
+ const effectiveMaxRetries = retryConfig.maxRetries ?? DEFAULT_MAX_RETRIES;
431
+ if (effectiveMaxRetries === 0) {
432
+ const durationMs = Date.now() - startTime;
433
+ this.emit("failure", {
434
+ ticketId: ticket.id,
435
+ url: displayUrl,
436
+ partition: partitionName,
437
+ attempts: 1,
438
+ durationMs,
439
+ queuedMs,
440
+ error,
441
+ });
442
+ controller.markDone({ success: false, error });
443
+ cleanup();
444
+ return;
445
+ }
446
+ if (this.vetoed(retryConfig, error, 0, options, ticket, controller, displayUrl, partitionName, startTime, queuedMs)) {
447
+ cleanup();
448
+ return;
449
+ }
450
+ controller.markQueued();
451
+ this._scheduleInBulkhead(ticket, controller, fullUrl, displayUrl, options, timeoutConfig, retryConfig, partitionName, bulkhead, breaker, error, cleanup, startTime, queuedMs);
452
+ return;
453
+ }
454
+ }
455
+ }
456
+ finally {
457
+ permit.release();
458
+ }
459
+ }
460
+ /** Apply the unified retry gate after the first attempt failed (attempt 0).
461
+ * Returns true if the request must NOT be retried — the ticket is resolved
462
+ * with the raw error and must not be queued. */
463
+ vetoed(retryConfig, error, attempt, options, ticket, controller, displayUrl, partitionName, startTime, queuedMs) {
464
+ const ctx = {
465
+ method: options.method ?? "GET",
466
+ headers: options.headers,
467
+ idempotent: retryConfig.idempotent,
468
+ };
469
+ if (!shouldRetry(error, attempt, ctx, retryConfig.retryWhen)) {
470
+ const durationMs = Date.now() - startTime;
471
+ this.emit("failure", {
472
+ ticketId: ticket.id,
473
+ url: displayUrl,
474
+ partition: partitionName,
475
+ attempts: 1,
476
+ durationMs,
477
+ queuedMs,
478
+ error,
479
+ });
480
+ controller.markDone({ success: false, error });
481
+ return true;
482
+ }
483
+ return false;
484
+ }
485
+ _scheduleInBulkhead(ticket, controller, fullUrl, displayUrl, options, timeoutConfig, retryConfig, partitionName, bulkhead, breaker, firstError, cleanup, startTime, initialQueuedMs) {
486
+ this.logger?.info("Request queued for retry", {
487
+ ticketId: ticket.id,
488
+ url: displayUrl,
489
+ partition: partitionName,
490
+ });
491
+ // Run the retry loop directly — each attempt inside the loop acquires its
492
+ // own bulkhead slot via bulkhead.run() so other tickets are not blocked
493
+ // for the entire retry lifetime (#5, D4). QueueFullError from the loop
494
+ // is caught here and surfaced as a terminal ticket failure.
495
+ runRetryLoop({
496
+ url: fullUrl,
497
+ displayUrl,
498
+ requestOptions: options,
499
+ timeoutConfig,
500
+ retryConfig,
501
+ deadlineAt: isBoundedMs(timeoutConfig.totalMs) ? startTime + timeoutConfig.totalMs : undefined,
502
+ ticket,
503
+ controller,
504
+ middleware: this.middlewares,
505
+ bulkhead,
506
+ semaphore: this.bulkheads.getSemaphore(),
507
+ circuitBreaker: breaker,
508
+ partition: partitionName,
509
+ firstError,
510
+ initialQueuedMs,
511
+ fetch: this.customFetch,
512
+ onRetry: (attempt, delayMs, error) => {
513
+ this.emit("retry", {
514
+ ticketId: ticket.id,
515
+ url: displayUrl,
516
+ partition: partitionName,
517
+ attempt,
518
+ delayMs,
519
+ error,
520
+ });
521
+ },
522
+ onSuccess: (statusCode, attempts, queuedMs) => {
523
+ const durationMs = Date.now() - startTime;
524
+ this.emit("success", {
525
+ ticketId: ticket.id,
526
+ url: displayUrl,
527
+ partition: partitionName,
528
+ attempts,
529
+ durationMs,
530
+ queuedMs,
531
+ statusCode,
532
+ });
533
+ },
534
+ onFailure: (error, attempts, queuedMs) => {
535
+ const durationMs = Date.now() - startTime;
536
+ this.emit("failure", {
537
+ ticketId: ticket.id,
538
+ url: displayUrl,
539
+ partition: partitionName,
540
+ attempts,
541
+ durationMs,
542
+ queuedMs,
543
+ error,
544
+ });
545
+ this.logger?.warn("Request failed after retries", {
546
+ ticketId: ticket.id,
547
+ url: displayUrl,
548
+ error: error.message,
549
+ });
550
+ },
551
+ onCancelled: (attempts, queuedMs) => {
552
+ const durationMs = Date.now() - startTime;
553
+ this.emit("cancelled", {
554
+ ticketId: ticket.id,
555
+ url: displayUrl,
556
+ partition: partitionName,
557
+ attempts,
558
+ durationMs,
559
+ queuedMs,
560
+ });
561
+ },
562
+ onCleanup: cleanup,
563
+ }).catch((err) => {
564
+ // A pre-typed RequestError (e.g. QueueFullError) has already been
565
+ // emitted and marked done inside runRetryLoop before it re-throws —
566
+ // that's the only place with the real accumulated queuedMs. Only a
567
+ // genuinely unexpected throw (ticket still not "done") needs this
568
+ // catch to emit failure itself, falling back to initialQueuedMs
569
+ // since no attempt-loop total exists for an error this early.
570
+ const error = isAppError(err)
571
+ ? err
572
+ : new NetworkError(err instanceof Error ? err.message : "Queue error", { cause: err });
573
+ if (!ticket.isSettled) {
574
+ this.emit("failure", {
575
+ ticketId: ticket.id,
576
+ url: displayUrl,
577
+ partition: partitionName,
578
+ attempts: 1,
579
+ durationMs: Date.now() - startTime,
580
+ queuedMs: initialQueuedMs,
581
+ error,
582
+ });
583
+ controller.markDone({ success: false, error });
584
+ }
585
+ cleanup();
586
+ });
587
+ }
588
+ get(url, options = {}) {
589
+ return this.send(url, { ...options, method: "GET" });
590
+ }
591
+ head(url, options = {}) {
592
+ return this.send(url, { ...options, method: "HEAD" });
593
+ }
594
+ options(url, options = {}) {
595
+ return this.send(url, { ...options, method: "OPTIONS" });
596
+ }
597
+ post(url, body, options = {}) {
598
+ return this.send(url, { ...options, method: "POST", body });
599
+ }
600
+ put(url, body, options = {}) {
601
+ return this.send(url, { ...options, method: "PUT", body });
602
+ }
603
+ patch(url, body, options = {}) {
604
+ return this.send(url, { ...options, method: "PATCH", body });
605
+ }
606
+ delete(url, options = {}) {
607
+ return this.send(url, { ...options, method: "DELETE" });
608
+ }
609
+ // ---------------------------------------------------------------------------
610
+ // Partition snapshots
611
+ // ---------------------------------------------------------------------------
612
+ /** Return a snapshot of all active bulkhead partitions.
613
+ * Each entry includes the partition name, running/queued counts,
614
+ * and configured concurrency/maxQueueSize limits. */
615
+ partitions() {
616
+ return this.bulkheads.getAll();
617
+ }
618
+ // ---------------------------------------------------------------------------
619
+ // Graceful shutdown
620
+ // ---------------------------------------------------------------------------
621
+ /** Close the client. New requests throw `ConfigurationError("client closed")`.
622
+ * When `drain` is true, in-flight tickets are awaited up to `timeoutMs`
623
+ * before the promise resolves, then remaining tickets are cancelled.
624
+ * When `drain` is false (default), all in-flight tickets are cancelled
625
+ * immediately.
626
+ *
627
+ * `timeoutMs` is required when `drain` is true to prevent indefinite
628
+ * hangs — use a value that fits your shutdown budget. */
629
+ close(opts) {
630
+ // Validate before committing to close: a rejected call must leave the
631
+ // client open, or a corrected retry would no-op without draining (B8).
632
+ if (opts?.drain && (!opts.timeoutMs || opts.timeoutMs <= 0)) {
633
+ return Promise.reject(new ConfigurationError("close({ drain: true }) requires a positive timeoutMs"));
634
+ }
635
+ // Idempotent, and a second caller waits for the same shutdown instead
636
+ // of resolving while the first one is still draining.
637
+ this._closing ??= this._close(opts);
638
+ return this._closing;
639
+ }
640
+ async _close(opts) {
641
+ this._closed = true;
642
+ const { drain = false, timeoutMs = 0 } = opts ?? {};
643
+ const entries = [...this._inflightTickets];
644
+ if (!drain || entries.length === 0) {
645
+ // Cancel all in-flight immediately
646
+ for (const entry of entries) {
647
+ entry.cleanup();
648
+ entry.ticket.cancel();
649
+ }
650
+ this._inflightTickets.clear();
651
+ return;
652
+ }
653
+ // Drain: wait for all to resolve, up to timeoutMs
654
+ const done = Promise.all(entries.map((e) => e.ticket.toPromise()));
655
+ let timer;
656
+ const timeout = new Promise((resolve) => {
657
+ timer = setTimeout(resolve, timeoutMs);
658
+ timer.unref();
659
+ });
660
+ await Promise.race([done, timeout]);
661
+ // Cancel any remaining in-flight tickets and cleanup
662
+ for (const entry of this._inflightTickets) {
663
+ entry.cleanup();
664
+ entry.ticket.cancel();
665
+ }
666
+ // Clear the timeout timer if tickets resolved first
667
+ if (timer !== undefined) {
668
+ clearTimeout(timer);
669
+ }
670
+ this._inflightTickets.clear();
671
+ }
672
+ // ---------------------------------------------------------------------------
673
+ // Helpers
674
+ // ---------------------------------------------------------------------------
675
+ /** The partition `_fireFirstAttempt` will use, or undefined when the URL
676
+ * can't be resolved (that path surfaces the error on the ticket). */
677
+ tryResolvePartition(url, options) {
678
+ if (options.partition !== undefined)
679
+ return options.partition;
680
+ try {
681
+ return new URL(this.resolveUrl(url)).host;
682
+ }
683
+ catch {
684
+ return undefined;
685
+ }
686
+ }
687
+ resolveUrl(url) {
688
+ if (this.config.baseUrl) {
689
+ return new URL(url, this.config.baseUrl).toString();
690
+ }
691
+ return url;
692
+ }
693
+ mergeTimeout(options, partitionName) {
694
+ const partitionConfig = partitionName ? this.partitionConfigs[partitionName] : undefined;
695
+ return {
696
+ ...this.config.timeout,
697
+ ...partitionConfig?.timeout,
698
+ ...options.timeout,
699
+ };
700
+ }
701
+ mergeRetry(options, partitionName) {
702
+ const partitionConfig = partitionName ? this.partitionConfigs[partitionName] : undefined;
703
+ return {
704
+ ...this.config.retry,
705
+ ...partitionConfig?.retry,
706
+ ...options.retry,
707
+ };
708
+ }
709
+ /** Returns the URL for logging, redacted if redactQuery is enabled. */
710
+ logUrl(url) {
711
+ return this.redactQuery ? redactUrl(url) : url;
712
+ }
713
+ /** Never throws: listener and metrics-sink errors are isolated (B3), since
714
+ * callers emit mid-transition — e.g. `success` right before `markDone`. */
715
+ emit(event, data) {
716
+ emitIsolated(this.emitter, event, data);
717
+ try {
718
+ this.emitMetrics(event, data);
719
+ }
720
+ catch (err) {
721
+ reportCallbackError(err);
722
+ }
723
+ }
724
+ emitMetrics(event, data) {
725
+ if (this.metrics) {
726
+ const e = event;
727
+ if (e === "request") {
728
+ const d = data;
729
+ this.metrics.counter(METRICS.REQUESTS, 1, {
730
+ partition: d.partition,
731
+ method: d.method,
732
+ });
733
+ this.metrics.gauge(METRICS.IN_FLIGHT, this._inflightTickets.size);
734
+ this.emitQueueDepthGauges();
735
+ }
736
+ else if (e === "retry") {
737
+ const d = data;
738
+ this.metrics.counter(METRICS.RETRIES, 1, { partition: d.partition, kind: d.error.kind });
739
+ }
740
+ else if (e === "success" || e === "failure" || e === "cancelled") {
741
+ const d = data;
742
+ const kind = e === "success" ? "success" : e === "failure" ? d.error.kind : "cancelled";
743
+ // A failure whose URL never resolved has no partition — omit the tag
744
+ // rather than inventing a value for it.
745
+ this.metrics.histogram(METRICS.DURATION, d.durationMs, d.partition === undefined ? { kind } : { partition: d.partition, kind });
746
+ this.metrics.gauge(METRICS.IN_FLIGHT, this._inflightTickets.size);
747
+ this.emitQueueDepthGauges();
748
+ }
749
+ else if (e === "circuitOpen") {
750
+ const d = data;
751
+ this.metrics.counter(METRICS.CIRCUIT_OPEN, 1, { partition: d.partition });
752
+ }
753
+ }
754
+ }
755
+ /** Push current queue-depth gauges: per-partition bulkhead backlog and the
756
+ * global semaphore backlog (the D1 cap devs most need visibility into).
757
+ * Polled from emit()'s request-start/terminal-event hooks rather than
758
+ * pushed on the semaphore's own enqueue/dequeue, so a single request that
759
+ * is briefly queued and released between those two hooks can land on
760
+ * neither poll and never register — reliable for a sustained backlog,
761
+ * not for a lone momentary wait (see docs/operations.md). `queuedMs` on
762
+ * the lifecycle events has no such gap; it's measured, not polled. */
763
+ emitQueueDepthGauges() {
764
+ if (!this.metrics)
765
+ return;
766
+ for (const snapshot of this.bulkheads.getAll()) {
767
+ this.metrics.gauge(METRICS.QUEUE_DEPTH, snapshot.queued, { partition: snapshot.name });
768
+ }
769
+ const semaphore = this.bulkheads.getSemaphore();
770
+ if (semaphore) {
771
+ this.metrics.gauge(METRICS.GLOBAL_QUEUE_DEPTH, semaphore.queueLength);
772
+ }
773
+ }
774
+ }
775
+ /** Parse JSON response body without validation.
776
+ * Useful when you just need the raw parsed JSON object/array.
777
+ * Unlike `parse` with Zod, this does not validate the shape. */
778
+ export function json() {
779
+ return (data) => data;
780
+ }
781
+ //# sourceMappingURL=client.js.map