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