@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,380 @@
|
|
|
1
|
+
import { buildBackoffFn, DEFAULT_MAX_DELAY_MS } from "../core/backoff.ts";
|
|
2
|
+
import type { AppError } from "../core/errors.ts";
|
|
3
|
+
import {
|
|
4
|
+
CancelledError,
|
|
5
|
+
CircuitOpenError,
|
|
6
|
+
DeadlineExceededError,
|
|
7
|
+
MaxRetriesExceededError,
|
|
8
|
+
NO_TIMEOUT_CONFIGURED,
|
|
9
|
+
QueueFullError,
|
|
10
|
+
RetryableStatusError,
|
|
11
|
+
TimeoutError,
|
|
12
|
+
} from "../core/errors.ts";
|
|
13
|
+
import {
|
|
14
|
+
type BackoffOptions,
|
|
15
|
+
DEFAULT_MAX_RETRIES,
|
|
16
|
+
isBoundedMs,
|
|
17
|
+
type RequestOptions,
|
|
18
|
+
type RetryConfig,
|
|
19
|
+
type TimeoutConfig,
|
|
20
|
+
} from "../core/types.ts";
|
|
21
|
+
import type { Ticket, TicketController } from "../ticket/ticket.ts";
|
|
22
|
+
import type { Bulkhead } from "./bulkhead.ts";
|
|
23
|
+
import type { CircuitBreaker } from "./circuit-breaker.ts";
|
|
24
|
+
import { executeRequest, type MiddlewareFn } from "./executor.ts";
|
|
25
|
+
import { defaultRetryPolicy, type RetryPolicyContext, shouldRetry } from "./policy.ts";
|
|
26
|
+
import type { Semaphore } from "./semaphore.ts";
|
|
27
|
+
|
|
28
|
+
export interface RetryJobOptions {
|
|
29
|
+
/** Fully resolved request URL (baseUrl applied) — what each retry fetches. */
|
|
30
|
+
url: string;
|
|
31
|
+
/** `url` as it may appear in errors/logs (query-redacted unless disabled). */
|
|
32
|
+
displayUrl: string;
|
|
33
|
+
requestOptions: RequestOptions<unknown>;
|
|
34
|
+
timeoutConfig: TimeoutConfig;
|
|
35
|
+
retryConfig: RetryConfig;
|
|
36
|
+
/** Absolute `Date.now()` timestamp of the ticket's whole-ticket deadline
|
|
37
|
+
* (`startTime + timeoutConfig.totalMs`), or undefined when `totalMs`
|
|
38
|
+
* isn't bounded. Forwarded to `executeRequest` so a handed-off Response
|
|
39
|
+
* body (unread success or `HttpError`) is bounded by it — see
|
|
40
|
+
* `ExecuteRequest.deadlineAt`. */
|
|
41
|
+
deadlineAt?: number;
|
|
42
|
+
ticket: Ticket<unknown>;
|
|
43
|
+
controller: TicketController<unknown>;
|
|
44
|
+
middleware: MiddlewareFn[];
|
|
45
|
+
/** Per-attempt bulkhead for retry scheduling. */
|
|
46
|
+
bulkhead: Bulkhead;
|
|
47
|
+
/** Global concurrency semaphore acquired after the partition slot (D4). */
|
|
48
|
+
semaphore?: Semaphore;
|
|
49
|
+
/** Per-partition circuit breaker. Inert unless configured/enabled. */
|
|
50
|
+
circuitBreaker: CircuitBreaker;
|
|
51
|
+
/** Partition name, used to construct CircuitOpenError when the breaker is open. */
|
|
52
|
+
partition: string;
|
|
53
|
+
/** The error from the first attempt (fired client-side before queuing). */
|
|
54
|
+
firstError: AppError;
|
|
55
|
+
/** Ms the first attempt spent waiting for a bulkhead/semaphore permit,
|
|
56
|
+
* carried over so the final queuedMs reported to the client covers the
|
|
57
|
+
* whole ticket lifetime, not just the retries run inside this loop. */
|
|
58
|
+
initialQueuedMs: number;
|
|
59
|
+
onRetry?: (attempt: number, delayMs: number, error: AppError) => void;
|
|
60
|
+
/** Called on success so the client can emit the success event with the
|
|
61
|
+
* response status code, the total attempt count, and the cumulative ms
|
|
62
|
+
* spent waiting for a bulkhead/semaphore permit across all attempts. */
|
|
63
|
+
onSuccess?: (statusCode: number, attempts: number, queuedMs: number) => void;
|
|
64
|
+
/** Called on failure (exhausted retries or policy veto inside the loop)
|
|
65
|
+
* so the client can emit the failure event. `queuedMs` is cumulative
|
|
66
|
+
* across all attempts made so far. */
|
|
67
|
+
onFailure?: (error: AppError, attempts: number, queuedMs: number) => void;
|
|
68
|
+
/** Called on cancellation (user cancel, deadline, or external signal)
|
|
69
|
+
* so the client can emit the cancelled event. `queuedMs` is cumulative
|
|
70
|
+
* across all attempts made so far. */
|
|
71
|
+
onCancelled?: (attempts: number, queuedMs: number) => void;
|
|
72
|
+
/** Called before markDone to clean up external resources (e.g. signal listeners). */
|
|
73
|
+
onCleanup?: () => void;
|
|
74
|
+
/** Custom fetch function. Falls back to globalThis.fetch. */
|
|
75
|
+
fetch?: typeof globalThis.fetch;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function runRetryLoop(job: RetryJobOptions): Promise<void> {
|
|
79
|
+
const {
|
|
80
|
+
url,
|
|
81
|
+
displayUrl,
|
|
82
|
+
requestOptions,
|
|
83
|
+
timeoutConfig,
|
|
84
|
+
retryConfig,
|
|
85
|
+
deadlineAt,
|
|
86
|
+
ticket,
|
|
87
|
+
controller,
|
|
88
|
+
middleware,
|
|
89
|
+
bulkhead,
|
|
90
|
+
semaphore,
|
|
91
|
+
circuitBreaker,
|
|
92
|
+
partition,
|
|
93
|
+
firstError,
|
|
94
|
+
initialQueuedMs,
|
|
95
|
+
onRetry,
|
|
96
|
+
onSuccess,
|
|
97
|
+
onFailure,
|
|
98
|
+
onCancelled,
|
|
99
|
+
onCleanup,
|
|
100
|
+
fetch: customFetch,
|
|
101
|
+
} = job;
|
|
102
|
+
|
|
103
|
+
const maxRetries = retryConfig.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
104
|
+
const backoffFn = buildBackoffFn(retryConfig.backoff);
|
|
105
|
+
const backoffCap =
|
|
106
|
+
retryConfig.backoff && typeof retryConfig.backoff === "object"
|
|
107
|
+
? ((retryConfig.backoff as BackoffOptions).maxDelayMs ?? DEFAULT_MAX_DELAY_MS)
|
|
108
|
+
: DEFAULT_MAX_DELAY_MS;
|
|
109
|
+
|
|
110
|
+
let lastError: AppError = firstError;
|
|
111
|
+
let totalAttempts = 1; // first attempt already fired client-side
|
|
112
|
+
let totalQueuedMs = initialQueuedMs; // cumulative bulkhead/semaphore wait across all attempts
|
|
113
|
+
|
|
114
|
+
// Attempt 0's error was already vetted (default policy + retryWhen) by
|
|
115
|
+
// client.ts's vetoed() before this loop was ever entered — that's what
|
|
116
|
+
// decided to queue the ticket for retry in the first place. `ctx` is
|
|
117
|
+
// constant across the loop (derived only from requestOptions/retryConfig),
|
|
118
|
+
// so it's built once here and reused both by the loop's gate below and by
|
|
119
|
+
// the final-attempt check after the loop.
|
|
120
|
+
const ctx: RetryPolicyContext = {
|
|
121
|
+
method: requestOptions.method ?? "GET",
|
|
122
|
+
headers: requestOptions.headers,
|
|
123
|
+
idempotent: retryConfig.idempotent,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
127
|
+
if (ticket.isCancelled) {
|
|
128
|
+
onCleanup?.();
|
|
129
|
+
onCancelled?.(totalAttempts, totalQueuedMs);
|
|
130
|
+
controller.markDone({
|
|
131
|
+
success: false,
|
|
132
|
+
error: new CancelledError(),
|
|
133
|
+
} as never);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const permit = circuitBreaker.tryAcquire();
|
|
138
|
+
if (!permit) {
|
|
139
|
+
onCleanup?.();
|
|
140
|
+
const error = new CircuitOpenError(partition);
|
|
141
|
+
onFailure?.(error, totalAttempts, totalQueuedMs);
|
|
142
|
+
controller.markDone({ success: false, error } as never);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Every exit from this iteration settles the permit: an outcome below,
|
|
147
|
+
// or release() for the veto/cancel/deadline/queue-full paths (B1).
|
|
148
|
+
try {
|
|
149
|
+
// Consult the default policy + retryWhen for every retry iteration
|
|
150
|
+
// *except the first* (attempt 0): that error is always `firstError`,
|
|
151
|
+
// already vetted by client.ts's vetoed() before this loop was ever
|
|
152
|
+
// entered. Re-checking it here would call a user-provided retryWhen
|
|
153
|
+
// twice for the same failed attempt. From attempt 1 on, `lastError`
|
|
154
|
+
// is a fresh error produced by a retry this loop just ran, so the
|
|
155
|
+
// gate must run — it's what lets retryWhen limit retries to N+1
|
|
156
|
+
// attempts total.
|
|
157
|
+
if (attempt > 0 && !shouldRetry(lastError, attempt, ctx, retryConfig.retryWhen)) {
|
|
158
|
+
onCleanup?.();
|
|
159
|
+
onFailure?.(lastError, totalAttempts, totalQueuedMs);
|
|
160
|
+
controller.markDone({ success: false, error: lastError } as never);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// All retries get backoff. Every retry is gated by the policy check above.
|
|
165
|
+
const delayMs = resolveRetryDelay(lastError, backoffFn, attempt, backoffCap);
|
|
166
|
+
onRetry?.(attempt, delayMs, lastError);
|
|
167
|
+
controller.markRetrying(attempt, delayMs);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await sleep(delayMs, ticket.signal);
|
|
171
|
+
} catch {
|
|
172
|
+
// The deadline timer aborts the ticket signal. Distinguish deadline from
|
|
173
|
+
// user cancellation: deadline only aborts the signal (abortSignal()),
|
|
174
|
+
// while user cancellation sets _cancelled = true via cancel().
|
|
175
|
+
onCleanup?.();
|
|
176
|
+
if (!ticket.isCancelled && isBoundedMs(timeoutConfig.totalMs)) {
|
|
177
|
+
const error = new DeadlineExceededError(displayUrl, timeoutConfig.totalMs);
|
|
178
|
+
onFailure?.(error, totalAttempts, totalQueuedMs);
|
|
179
|
+
controller.markDone({
|
|
180
|
+
success: false,
|
|
181
|
+
error,
|
|
182
|
+
} as never);
|
|
183
|
+
} else {
|
|
184
|
+
onCancelled?.(totalAttempts, totalQueuedMs);
|
|
185
|
+
controller.markDone({
|
|
186
|
+
success: false,
|
|
187
|
+
error: new CancelledError(),
|
|
188
|
+
} as never);
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (ticket.isCancelled) {
|
|
194
|
+
onCleanup?.();
|
|
195
|
+
onCancelled?.(totalAttempts, totalQueuedMs);
|
|
196
|
+
controller.markDone({
|
|
197
|
+
success: false,
|
|
198
|
+
error: new CancelledError(),
|
|
199
|
+
} as never);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
totalAttempts++;
|
|
204
|
+
|
|
205
|
+
// Per-attempt bulkhead scheduling: each retry acquires its own slot,
|
|
206
|
+
// releases it after execution, so other tickets aren't blocked (#5, D4).
|
|
207
|
+
// The global semaphore is acquired after the partition slot (D4).
|
|
208
|
+
let result: Awaited<ReturnType<typeof executeRequest>>;
|
|
209
|
+
try {
|
|
210
|
+
result = await bulkhead.run(
|
|
211
|
+
() =>
|
|
212
|
+
executeRequest(
|
|
213
|
+
{
|
|
214
|
+
url,
|
|
215
|
+
options: requestOptions,
|
|
216
|
+
timeoutConfig,
|
|
217
|
+
retryConfig,
|
|
218
|
+
deadlineAt,
|
|
219
|
+
displayUrl,
|
|
220
|
+
signal: ticket.signal,
|
|
221
|
+
attempt: attempt + 1,
|
|
222
|
+
ticketId: ticket.id,
|
|
223
|
+
partition: bulkhead.name,
|
|
224
|
+
fetch: customFetch,
|
|
225
|
+
},
|
|
226
|
+
middleware,
|
|
227
|
+
),
|
|
228
|
+
semaphore,
|
|
229
|
+
(queuedMs) => {
|
|
230
|
+
totalQueuedMs += queuedMs;
|
|
231
|
+
},
|
|
232
|
+
ticket.signal,
|
|
233
|
+
);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
if (err instanceof CancelledError) {
|
|
236
|
+
// Cancelled (or deadline-aborted) while still queued for a slot or
|
|
237
|
+
// permit (B9): nothing was dispatched, so back out the optimistic
|
|
238
|
+
// totalAttempts++ and let the "cancelled" case settle it.
|
|
239
|
+
totalAttempts--;
|
|
240
|
+
result = { kind: "cancelled" };
|
|
241
|
+
} else if (err instanceof QueueFullError) {
|
|
242
|
+
// Queue is at capacity — bulkhead.run() rejected before task()
|
|
243
|
+
// ever ran, so this iteration's optimistic totalAttempts++ above
|
|
244
|
+
// must be backed out; it reports attempts actually dispatched.
|
|
245
|
+
const attemptsMade = totalAttempts - 1;
|
|
246
|
+
onCleanup?.();
|
|
247
|
+
if (ticket.isCancelled) {
|
|
248
|
+
// cancel() may have resolved the ticket directly (ticket.ts),
|
|
249
|
+
// bypassing markDone, while this bulkhead/semaphore acquisition
|
|
250
|
+
// was still pending. Match every other cancellation checkpoint
|
|
251
|
+
// in this loop: notify via onCancelled, not onFailure, so the
|
|
252
|
+
// lifecycle-event stream agrees with the ticket's actual outcome.
|
|
253
|
+
onCancelled?.(attemptsMade, totalQueuedMs);
|
|
254
|
+
} else {
|
|
255
|
+
// Emit failure here (not in the client's outer .catch) because
|
|
256
|
+
// totalQueuedMs — the wait accumulated by retries that already
|
|
257
|
+
// ran — only exists in this closure; the outer catch only has
|
|
258
|
+
// the first attempt's queuedMs.
|
|
259
|
+
onFailure?.(err, attemptsMade, totalQueuedMs);
|
|
260
|
+
}
|
|
261
|
+
controller.markDone({ success: false, error: err } as never);
|
|
262
|
+
throw err;
|
|
263
|
+
} else {
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
switch (result.kind) {
|
|
269
|
+
case "success": {
|
|
270
|
+
permit.success();
|
|
271
|
+
onCleanup?.();
|
|
272
|
+
if (result.result.success) {
|
|
273
|
+
onSuccess?.(result.result.raw.status, totalAttempts, totalQueuedMs);
|
|
274
|
+
}
|
|
275
|
+
controller.markDone(result.result);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case "cancelled":
|
|
280
|
+
onCleanup?.();
|
|
281
|
+
if (!ticket.isCancelled && isBoundedMs(timeoutConfig.totalMs)) {
|
|
282
|
+
const error = new DeadlineExceededError(displayUrl, timeoutConfig.totalMs);
|
|
283
|
+
onFailure?.(error, totalAttempts, totalQueuedMs);
|
|
284
|
+
controller.markDone({
|
|
285
|
+
success: false,
|
|
286
|
+
error,
|
|
287
|
+
} as never);
|
|
288
|
+
} else {
|
|
289
|
+
onCancelled?.(totalAttempts, totalQueuedMs);
|
|
290
|
+
controller.markDone({
|
|
291
|
+
success: false,
|
|
292
|
+
error: new CancelledError(),
|
|
293
|
+
} as never);
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
|
|
297
|
+
case "timeout":
|
|
298
|
+
lastError = new TimeoutError(displayUrl, timeoutConfig.attemptMs ?? NO_TIMEOUT_CONFIGURED);
|
|
299
|
+
permit.failure(lastError);
|
|
300
|
+
break;
|
|
301
|
+
|
|
302
|
+
case "error":
|
|
303
|
+
lastError = result.error;
|
|
304
|
+
permit.failure(lastError);
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
} finally {
|
|
308
|
+
permit.release();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (maxRetries === 0) {
|
|
313
|
+
// Zero retries configured/executed — surface the underlying error raw,
|
|
314
|
+
// never wrapped in MaxRetriesExceededError.
|
|
315
|
+
onCleanup?.();
|
|
316
|
+
onFailure?.(lastError, totalAttempts, totalQueuedMs);
|
|
317
|
+
controller.markDone({ success: false, error: lastError } as never);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// All retries exhausted — total attempts = 1 (first) + maxRetries (loop)
|
|
322
|
+
totalAttempts = 1 + maxRetries;
|
|
323
|
+
onCleanup?.();
|
|
324
|
+
|
|
325
|
+
// The final attempt's own error was never run through the gate above —
|
|
326
|
+
// the loop only checks the *previous* error at the top of each iteration,
|
|
327
|
+
// and there is no next iteration for this one. If it wouldn't have been
|
|
328
|
+
// retried anyway (e.g. a non-retryable HttpError, or a ValidationError
|
|
329
|
+
// from a failed parse), it's terminal on its own merits: surface it raw,
|
|
330
|
+
// same as a mid-loop veto, instead of wrapping it in
|
|
331
|
+
// MaxRetriesExceededError. Only genuinely transient exhaustion — the
|
|
332
|
+
// final error was itself retryable, retries just ran out — gets wrapped.
|
|
333
|
+
// This intentionally checks the default policy only, not retryWhen:
|
|
334
|
+
// retryWhen is never consulted for an attempt that has no retries left.
|
|
335
|
+
if (!defaultRetryPolicy(lastError, maxRetries, ctx)) {
|
|
336
|
+
onFailure?.(lastError, totalAttempts, totalQueuedMs);
|
|
337
|
+
controller.markDone({ success: false, error: lastError } as never);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const exhaustedError = new MaxRetriesExceededError(totalAttempts, lastError);
|
|
342
|
+
onFailure?.(exhaustedError, totalAttempts, totalQueuedMs);
|
|
343
|
+
controller.markDone({
|
|
344
|
+
success: false,
|
|
345
|
+
error: exhaustedError,
|
|
346
|
+
} as never);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
350
|
+
return new Promise((resolve, reject) => {
|
|
351
|
+
if (signal?.aborted) {
|
|
352
|
+
reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError"));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const timer = setTimeout(() => {
|
|
356
|
+
signal?.removeEventListener("abort", onAbort);
|
|
357
|
+
resolve();
|
|
358
|
+
}, ms);
|
|
359
|
+
const onAbort = () => {
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
reject(signal?.reason ?? new DOMException("The operation was aborted", "AbortError"));
|
|
362
|
+
};
|
|
363
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Resolve the delay before a retry (decision D3): a Retry-After-derived
|
|
368
|
+
* `retryAfterMs` wins when present (capped at `cap`, no jitter), otherwise
|
|
369
|
+
* the configured backoff drives the delay. */
|
|
370
|
+
function resolveRetryDelay(
|
|
371
|
+
lastError: AppError,
|
|
372
|
+
backoffFn: (attempt: number) => number,
|
|
373
|
+
attempt: number,
|
|
374
|
+
cap: number,
|
|
375
|
+
): number {
|
|
376
|
+
if (lastError instanceof RetryableStatusError && lastError.retryAfterMs !== undefined) {
|
|
377
|
+
return Math.min(lastError.retryAfterMs, cap);
|
|
378
|
+
}
|
|
379
|
+
return backoffFn(attempt);
|
|
380
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CancelledError, QueueFullError } from "../core/errors.ts";
|
|
2
|
+
import { DEFAULT_GLOBAL_QUEUE_SIZE } from "../core/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A counting semaphore that limits total concurrent executions across all
|
|
6
|
+
* partitions (the global concurrency cap — decision D1, see
|
|
7
|
+
* `ClientConfig.concurrency` / `DEFAULT_GLOBAL_CONCURRENCY` for the default).
|
|
8
|
+
*
|
|
9
|
+
* Each `acquire()` returns a `release` callback. When no permit is available
|
|
10
|
+
* the caller is queued (up to `maxQueueSize`, see `ClientConfig.maxQueueSize` /
|
|
11
|
+
* `DEFAULT_GLOBAL_QUEUE_SIZE`) and resolved when a permit is released.
|
|
12
|
+
* Exceeding the queue limit rejects immediately with QueueFullError.
|
|
13
|
+
*/
|
|
14
|
+
export class Semaphore {
|
|
15
|
+
private available: number;
|
|
16
|
+
private readonly waitQueue: Array<() => void> = [];
|
|
17
|
+
private readonly maxQueueSize: number;
|
|
18
|
+
|
|
19
|
+
constructor(permits: number, maxQueueSize = DEFAULT_GLOBAL_QUEUE_SIZE) {
|
|
20
|
+
this.available = permits;
|
|
21
|
+
this.maxQueueSize = maxQueueSize;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Number of callers currently waiting for a permit. */
|
|
25
|
+
get queueLength(): number {
|
|
26
|
+
return this.waitQueue.length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Number of permits currently free (not held by an in-flight request). */
|
|
30
|
+
get availablePermits(): number {
|
|
31
|
+
return this.available;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Acquire a permit. Resolves to a `release` function when granted;
|
|
35
|
+
* calling `release` more than once is a no-op.
|
|
36
|
+
*
|
|
37
|
+
* If `signal` aborts while the caller is still queued, the waiter is
|
|
38
|
+
* removed from the queue — freeing its place for live callers — and the
|
|
39
|
+
* promise rejects with `CancelledError` (B9). An already-aborted signal
|
|
40
|
+
* rejects immediately without taking a permit. */
|
|
41
|
+
acquire(signal?: AbortSignal): Promise<() => void> {
|
|
42
|
+
if (signal?.aborted) {
|
|
43
|
+
return Promise.reject(new CancelledError());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (this.available > 0) {
|
|
47
|
+
this.available--;
|
|
48
|
+
return Promise.resolve(this.releaseOnce());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (this.waitQueue.length >= this.maxQueueSize) {
|
|
52
|
+
return Promise.reject(new QueueFullError("global", this.waitQueue.length, this.maxQueueSize));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return new Promise<() => void>((resolve, reject) => {
|
|
56
|
+
const onAbort = () => {
|
|
57
|
+
const idx = this.waitQueue.indexOf(grant);
|
|
58
|
+
if (idx !== -1) this.waitQueue.splice(idx, 1);
|
|
59
|
+
reject(new CancelledError());
|
|
60
|
+
};
|
|
61
|
+
const grant = () => {
|
|
62
|
+
signal?.removeEventListener("abort", onAbort);
|
|
63
|
+
this.available--;
|
|
64
|
+
resolve(this.releaseOnce());
|
|
65
|
+
};
|
|
66
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
67
|
+
this.waitQueue.push(grant);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private releaseOnce(): () => void {
|
|
72
|
+
let released = false;
|
|
73
|
+
return () => {
|
|
74
|
+
if (released) return;
|
|
75
|
+
released = true;
|
|
76
|
+
this.release();
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private release(): void {
|
|
81
|
+
this.available++;
|
|
82
|
+
this._drain();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private _drain(): void {
|
|
86
|
+
while (this.available > 0 && this.waitQueue.length > 0) {
|
|
87
|
+
const next = this.waitQueue.shift()!;
|
|
88
|
+
next();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|