@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.
- package/LICENSE +21 -0
- package/README.md +604 -0
- package/dist/adapters/zod.d.ts +14 -0
- package/dist/adapters/zod.d.ts.map +1 -0
- package/dist/adapters/zod.js +14 -0
- package/dist/adapters/zod.js.map +1 -0
- package/dist/core/backoff.d.ts +9 -0
- package/dist/core/backoff.d.ts.map +1 -0
- package/dist/core/backoff.js +23 -0
- package/dist/core/backoff.js.map +1 -0
- package/dist/core/client.d.ts +98 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +781 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/errors.d.ts +87 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +140 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +10 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/listeners.d.ts +14 -0
- package/dist/core/listeners.d.ts.map +1 -0
- package/dist/core/listeners.js +27 -0
- package/dist/core/listeners.js.map +1 -0
- package/dist/core/metrics.d.ts +33 -0
- package/dist/core/metrics.d.ts.map +1 -0
- package/dist/core/metrics.js +24 -0
- package/dist/core/metrics.js.map +1 -0
- package/dist/core/nanoid.d.ts +2 -0
- package/dist/core/nanoid.d.ts.map +1 -0
- package/dist/core/nanoid.js +11 -0
- package/dist/core/nanoid.js.map +1 -0
- package/dist/core/redact.d.ts +12 -0
- package/dist/core/redact.d.ts.map +1 -0
- package/dist/core/redact.js +42 -0
- package/dist/core/redact.js.map +1 -0
- package/dist/core/types.d.ts +261 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +41 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/validate.d.ts +19 -0
- package/dist/core/validate.d.ts.map +1 -0
- package/dist/core/validate.js +135 -0
- package/dist/core/validate.js.map +1 -0
- package/dist/middleware/index.d.ts +26 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +55 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/queue/bulkhead.d.ts +63 -0
- package/dist/queue/bulkhead.d.ts.map +1 -0
- package/dist/queue/bulkhead.js +192 -0
- package/dist/queue/bulkhead.js.map +1 -0
- package/dist/queue/circuit-breaker.d.ts +81 -0
- package/dist/queue/circuit-breaker.d.ts.map +1 -0
- package/dist/queue/circuit-breaker.js +283 -0
- package/dist/queue/circuit-breaker.js.map +1 -0
- package/dist/queue/executor.d.ts +67 -0
- package/dist/queue/executor.d.ts.map +1 -0
- package/dist/queue/executor.js +273 -0
- package/dist/queue/executor.js.map +1 -0
- package/dist/queue/policy.d.ts +26 -0
- package/dist/queue/policy.d.ts.map +1 -0
- package/dist/queue/policy.js +37 -0
- package/dist/queue/policy.js.map +1 -0
- package/dist/queue/retry.d.ts +58 -0
- package/dist/queue/retry.d.ts.map +1 -0
- package/dist/queue/retry.js +259 -0
- package/dist/queue/retry.js.map +1 -0
- package/dist/queue/semaphore.d.ts +32 -0
- package/dist/queue/semaphore.d.ts.map +1 -0
- package/dist/queue/semaphore.js +83 -0
- package/dist/queue/semaphore.js.map +1 -0
- package/dist/ticket/ticket.d.ts +77 -0
- package/dist/ticket/ticket.d.ts.map +1 -0
- package/dist/ticket/ticket.js +186 -0
- package/dist/ticket/ticket.js.map +1 -0
- package/package.json +85 -0
- package/src/adapters/zod.ts +16 -0
- package/src/core/backoff.ts +26 -0
- package/src/core/client.ts +1048 -0
- package/src/core/errors.ts +194 -0
- package/src/core/index.ts +56 -0
- package/src/core/listeners.ts +28 -0
- package/src/core/metrics.ts +42 -0
- package/src/core/nanoid.ts +11 -0
- package/src/core/redact.ts +46 -0
- package/src/core/types.ts +306 -0
- package/src/core/validate.ts +163 -0
- package/src/middleware/index.ts +63 -0
- package/src/queue/bulkhead.ts +243 -0
- package/src/queue/circuit-breaker.ts +373 -0
- package/src/queue/executor.ts +355 -0
- package/src/queue/policy.ts +49 -0
- package/src/queue/retry.ts +380 -0
- package/src/queue/semaphore.ts +91 -0
- 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
|