@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,355 @@
|
|
|
1
|
+
import type { AppError } from "../core/errors.ts";
|
|
2
|
+
import {
|
|
3
|
+
ConfigurationError,
|
|
4
|
+
DeadlineExceededError,
|
|
5
|
+
HttpError,
|
|
6
|
+
NetworkError,
|
|
7
|
+
NO_TIMEOUT_CONFIGURED,
|
|
8
|
+
RetryableStatusError,
|
|
9
|
+
TimeoutError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
} from "../core/errors.ts";
|
|
12
|
+
import type { RequestOptions, Result, RetryConfig, TimeoutConfig } from "../core/types.ts";
|
|
13
|
+
import { DEFAULT_RETRY_ON_STATUS, isBoundedMs } from "../core/types.ts";
|
|
14
|
+
import { isReadableStream } from "../core/validate.ts";
|
|
15
|
+
|
|
16
|
+
export interface ExecuteRequest {
|
|
17
|
+
url: string;
|
|
18
|
+
options: RequestOptions<unknown>;
|
|
19
|
+
timeoutConfig: TimeoutConfig;
|
|
20
|
+
retryConfig: RetryConfig;
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
/** Which attempt this is: 0 = the first attempt, 1 = the first retry, etc.
|
|
23
|
+
* Exposed to middleware via `RequestContext.attempt`. */
|
|
24
|
+
attempt: number;
|
|
25
|
+
ticketId: string;
|
|
26
|
+
partition: string;
|
|
27
|
+
/** Absolute `Date.now()` timestamp of the ticket's whole-ticket deadline
|
|
28
|
+
* (`startTime + timeout.totalMs`), or undefined when `totalMs` isn't
|
|
29
|
+
* bounded. Used only to bound the read window of a Response handed back
|
|
30
|
+
* to the caller unread (see `handedOff` in `executeRequest`) — never to
|
|
31
|
+
* reclassify an in-attempt timeout. */
|
|
32
|
+
deadlineAt?: number;
|
|
33
|
+
/** `url` as it may appear in errors/logs (query- and userinfo-redacted
|
|
34
|
+
* unless disabled) — used to build the `TimeoutError`/`DeadlineExceededError`
|
|
35
|
+
* that bounds a handed-off Response's body-read window, so that error is
|
|
36
|
+
* redacted the same as every other error the client builds. */
|
|
37
|
+
displayUrl: string;
|
|
38
|
+
/** Custom fetch function. Falls back to globalThis.fetch. */
|
|
39
|
+
fetch?: typeof globalThis.fetch;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ExecuteResult =
|
|
43
|
+
| { kind: "success"; result: Result<unknown> }
|
|
44
|
+
| { kind: "timeout" }
|
|
45
|
+
| { kind: "cancelled" }
|
|
46
|
+
| { kind: "error"; error: AppError };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Executes a single HTTP request attempt.
|
|
50
|
+
* Returns a discriminated union describing what happened,
|
|
51
|
+
* so the caller (retry loop) can decide whether to retry or resolve.
|
|
52
|
+
*/
|
|
53
|
+
export async function executeRequest(req: ExecuteRequest, middleware: MiddlewareFn[]): Promise<ExecuteResult> {
|
|
54
|
+
const { url, options, timeoutConfig, retryConfig, signal } = req;
|
|
55
|
+
const attemptStart = Date.now();
|
|
56
|
+
|
|
57
|
+
if (signal.aborted || options.signal?.aborted) {
|
|
58
|
+
return { kind: "cancelled" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Set once this attempt hands the caller a live, unread Response body (the
|
|
62
|
+
// success-without-parse and HttpError returns below). An unconsumed body
|
|
63
|
+
// handed off this way gets bounded in `finally` instead of having its
|
|
64
|
+
// timer cleared, so `raw.json()`/`error.response.text()` read later can't
|
|
65
|
+
// hang forever (the parse path already reads the body inside the attempt,
|
|
66
|
+
// and the RetryableStatusError path already cancels it).
|
|
67
|
+
let handedOff = false;
|
|
68
|
+
|
|
69
|
+
// Resolve a replayable body factory fresh for this attempt so every attempt
|
|
70
|
+
// gets its own materialized body. Do not mutate the caller's options.
|
|
71
|
+
let resolvedBody: BodyInit | undefined;
|
|
72
|
+
if (typeof options.body === "function") {
|
|
73
|
+
try {
|
|
74
|
+
resolvedBody = (options.body as () => BodyInit)();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return {
|
|
77
|
+
kind: "error",
|
|
78
|
+
error: new ConfigurationError(`body factory threw: ${err instanceof Error ? err.message : String(err)}`),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
resolvedBody = options.body;
|
|
83
|
+
}
|
|
84
|
+
const timeoutMs = timeoutConfig.attemptMs;
|
|
85
|
+
const retryOnStatus = retryConfig.retryOnStatus ?? DEFAULT_RETRY_ON_STATUS;
|
|
86
|
+
|
|
87
|
+
// Build the fetch call wrapped in middleware
|
|
88
|
+
const fetchCall = buildFetchCall(req.fetch);
|
|
89
|
+
const composed = composeMiddleware(middleware, fetchCall);
|
|
90
|
+
|
|
91
|
+
// Merge the per-attempt signals with AbortSignal.any. It wires its sources
|
|
92
|
+
// internally (no "abort" listeners attached to them), so neither the ticket
|
|
93
|
+
// signal nor the caller's signal accumulates one listener per attempt (#7).
|
|
94
|
+
// Wrapping even a lone ticket signal shields it from fetch's own abort
|
|
95
|
+
// listener, which undici only removes asynchronously after completion.
|
|
96
|
+
const sources: AbortSignal[] = [signal];
|
|
97
|
+
if (options.signal) sources.push(options.signal);
|
|
98
|
+
// `Infinity` is a legal, explicit "no cap" value (see isBoundedMs) — it must
|
|
99
|
+
// be treated the same as "not set" here. Node clamps any setTimeout delay
|
|
100
|
+
// over ~24.8 days to 1ms, so passing Infinity straight to setTimeout would
|
|
101
|
+
// fire the timer almost immediately instead of never.
|
|
102
|
+
const hasAttemptTimeout = isBoundedMs(timeoutMs);
|
|
103
|
+
// Always created (not just when attemptMs is bounded): a handed-off
|
|
104
|
+
// Response's body read may still need bounding by `deadlineAt` alone in
|
|
105
|
+
// `finally` below, even when this attempt itself has no attemptMs cap.
|
|
106
|
+
const timeoutController = new AbortController();
|
|
107
|
+
sources.push(timeoutController.signal);
|
|
108
|
+
const attemptSignal = AbortSignal.any(sources);
|
|
109
|
+
const timeoutId = hasAttemptTimeout ? setTimeout(() => timeoutController.abort(), timeoutMs) : undefined;
|
|
110
|
+
|
|
111
|
+
// A fresh Headers instance per attempt: middleware (e.g. defaultHeaders)
|
|
112
|
+
// mutates ctx.headers in place, and that must never leak into the next
|
|
113
|
+
// retry's "starting" headers.
|
|
114
|
+
const ctx: RequestContext = {
|
|
115
|
+
url,
|
|
116
|
+
method: options.method ?? "GET",
|
|
117
|
+
headers: new Headers(options.headers),
|
|
118
|
+
body: resolvedBody,
|
|
119
|
+
signal: attemptSignal,
|
|
120
|
+
attempt: req.attempt,
|
|
121
|
+
ticketId: req.ticketId,
|
|
122
|
+
partition: req.partition,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
let response: Response;
|
|
126
|
+
try {
|
|
127
|
+
response = await composed(ctx);
|
|
128
|
+
// The timeout may fire after fetch resolves but before this check runs;
|
|
129
|
+
// the timed-out attempt is not trustworthy, so it still surfaces as a
|
|
130
|
+
// timeout (cancellation is checked first in the catch path below).
|
|
131
|
+
if (timeoutController.signal.aborted) {
|
|
132
|
+
return { kind: "timeout" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check if status code is retryable (was "queued_status", now typed error)
|
|
136
|
+
if (retryOnStatus.includes(response.status)) {
|
|
137
|
+
// Cancel the response body since caller won't read it.
|
|
138
|
+
// Fire-and-forget: cancel() may hang on stuck connections.
|
|
139
|
+
// The body will be GC'd when the response is collected.
|
|
140
|
+
response.body?.cancel().catch(() => {});
|
|
141
|
+
return {
|
|
142
|
+
kind: "error",
|
|
143
|
+
error: new RetryableStatusError(
|
|
144
|
+
`HTTP ${response.status} ${response.statusText}`,
|
|
145
|
+
response.status,
|
|
146
|
+
response,
|
|
147
|
+
parseRetryAfter(response.headers.get("retry-after")),
|
|
148
|
+
),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Non-2xx responses are non-retryable errors. The body is handed back
|
|
153
|
+
// unread on `error.response` — bound its read window in `finally`.
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
handedOff = true;
|
|
156
|
+
return {
|
|
157
|
+
kind: "error",
|
|
158
|
+
error: new HttpError(`HTTP ${response.status} ${response.statusText}`, response.status, response),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Parse body — the timeout is still active so a slow body read is
|
|
163
|
+
// covered by the per-attempt deadline (#9).
|
|
164
|
+
if (options.parse) {
|
|
165
|
+
let raw: unknown;
|
|
166
|
+
try {
|
|
167
|
+
raw = await response.json();
|
|
168
|
+
} catch (err) {
|
|
169
|
+
// Check timeout first — if our timer fired during response.json(),
|
|
170
|
+
// that is the cause regardless of whether the external signal also
|
|
171
|
+
// aborted (cancellation vs timeout precedence).
|
|
172
|
+
if (timeoutController.signal.aborted) {
|
|
173
|
+
return { kind: "timeout" };
|
|
174
|
+
}
|
|
175
|
+
if (signal.aborted || options.signal?.aborted) {
|
|
176
|
+
return { kind: "cancelled" };
|
|
177
|
+
}
|
|
178
|
+
// The body arrived but isn't JSON: the server answered, and asking
|
|
179
|
+
// again will get the same answer — a parse failure, never retried
|
|
180
|
+
// (B6). Anything else here is the body stream dying mid-read
|
|
181
|
+
// (undici's "terminated" TypeError), which is a real network error.
|
|
182
|
+
if (err instanceof SyntaxError) {
|
|
183
|
+
return {
|
|
184
|
+
kind: "error",
|
|
185
|
+
error: new ValidationError("Response body is not valid JSON", [err], err),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
kind: "error",
|
|
190
|
+
error: new NetworkError("Failed to parse response body as JSON", {
|
|
191
|
+
cause: err,
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const data = options.parse(raw);
|
|
198
|
+
return {
|
|
199
|
+
kind: "success",
|
|
200
|
+
result: { success: true, data, raw: response },
|
|
201
|
+
};
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const issues = extractIssues(err);
|
|
204
|
+
return {
|
|
205
|
+
kind: "error",
|
|
206
|
+
error: new ValidationError("Response validation failed", issues, err),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// No parse fn — return raw response, unread. Bound its read window in
|
|
212
|
+
// `finally` instead of clearing the attempt timer.
|
|
213
|
+
handedOff = true;
|
|
214
|
+
return {
|
|
215
|
+
kind: "success",
|
|
216
|
+
result: { success: true, data: undefined, raw: response },
|
|
217
|
+
};
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// Precedence: cancellation wins over timeout. If the ticket or the
|
|
220
|
+
// external signal is aborted, report cancelled even if the timeout
|
|
221
|
+
// also fired — the ticket is already marked cancelled, and
|
|
222
|
+
// cancellation is the caller's terminal intent. A timeout that fires
|
|
223
|
+
// first still surfaces as "timeout": the external signal is not yet
|
|
224
|
+
// aborted when this check runs.
|
|
225
|
+
if (signal.aborted || options.signal?.aborted) {
|
|
226
|
+
return { kind: "cancelled" };
|
|
227
|
+
}
|
|
228
|
+
if (isAbortError(err)) {
|
|
229
|
+
// Could be our timeout abort
|
|
230
|
+
return { kind: "timeout" };
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
kind: "error",
|
|
234
|
+
error: new NetworkError(err instanceof Error ? err.message : "Network error", { cause: err }),
|
|
235
|
+
};
|
|
236
|
+
} finally {
|
|
237
|
+
// Clear the in-attempt timer either way — it did its job (bounding the
|
|
238
|
+
// attempt) or the attempt failed on its own. Body has been read (or the
|
|
239
|
+
// attempt failed), so the abort controller can be released.
|
|
240
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
241
|
+
|
|
242
|
+
// A handed-off Response (unread body on a success-without-parse or
|
|
243
|
+
// HttpError result) gets the same bound its body would have had under
|
|
244
|
+
// `parse`: arm a fresh timer capped at whichever is sooner, this
|
|
245
|
+
// attempt's remaining attemptMs or the ticket's totalMs deadline. It
|
|
246
|
+
// fires only if the caller actually reads the body later — aborting
|
|
247
|
+
// `attemptSignal` (already wired into fetch) makes that read reject,
|
|
248
|
+
// same as any other abort observed after fetch() has resolved. No
|
|
249
|
+
// ticket state changes here: the ticket already resolved.
|
|
250
|
+
if (handedOff) {
|
|
251
|
+
const attemptTimeoutMs = isBoundedMs(timeoutMs) ? timeoutMs : undefined;
|
|
252
|
+
const attemptDeadline =
|
|
253
|
+
attemptTimeoutMs !== undefined ? attemptStart + attemptTimeoutMs : Number.POSITIVE_INFINITY;
|
|
254
|
+
const totalDeadline = req.deadlineAt ?? Number.POSITIVE_INFINITY;
|
|
255
|
+
const boundAt = Math.min(attemptDeadline, totalDeadline);
|
|
256
|
+
if (Number.isFinite(boundAt)) {
|
|
257
|
+
const delay = Math.max(0, boundAt - Date.now());
|
|
258
|
+
// Name the abort reason after whichever bound fired — ties go to the
|
|
259
|
+
// attempt bound, since it's also the only bound when totalMs isn't
|
|
260
|
+
// configured — using Vereda's own error classes instead of a bare
|
|
261
|
+
// DOMException. That way a caller's `raw.json()`/`error.response.text()`
|
|
262
|
+
// rejects with something `instanceof TimeoutError`/`DeadlineExceededError`,
|
|
263
|
+
// `.kind`-narrowable, and redacted the same as every other error the
|
|
264
|
+
// client builds. The final branch is defensive only: `boundAt` being
|
|
265
|
+
// finite guarantees one of the two bounds above is actually configured.
|
|
266
|
+
const reason =
|
|
267
|
+
attemptTimeoutMs !== undefined && attemptDeadline <= totalDeadline
|
|
268
|
+
? new TimeoutError(req.displayUrl, attemptTimeoutMs)
|
|
269
|
+
: isBoundedMs(req.timeoutConfig.totalMs)
|
|
270
|
+
? new DeadlineExceededError(req.displayUrl, req.timeoutConfig.totalMs)
|
|
271
|
+
: new TimeoutError(req.displayUrl, attemptTimeoutMs ?? NO_TIMEOUT_CONFIGURED);
|
|
272
|
+
const bodyReadTimer = setTimeout(() => {
|
|
273
|
+
timeoutController.abort(reason);
|
|
274
|
+
}, delay);
|
|
275
|
+
bodyReadTimer.unref();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Middleware types + composition
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/** What a middleware function sees and can rewrite for a single attempt.
|
|
286
|
+
* `headers` is a real `Headers` instance (case-insensitive lookups/sets) and
|
|
287
|
+
* is fresh per attempt — mutating it does not affect other attempts or the
|
|
288
|
+
* caller's original `RequestOptions`. */
|
|
289
|
+
export interface RequestContext {
|
|
290
|
+
url: string;
|
|
291
|
+
method: string;
|
|
292
|
+
headers: Headers;
|
|
293
|
+
body?: BodyInit;
|
|
294
|
+
signal: AbortSignal;
|
|
295
|
+
/** 0 = the first attempt, 1 = the first retry, etc. */
|
|
296
|
+
attempt: number;
|
|
297
|
+
ticketId: string;
|
|
298
|
+
partition: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export type NextFn = (ctx: RequestContext) => Promise<Response>;
|
|
302
|
+
export type MiddlewareFn = (ctx: RequestContext, next: NextFn) => Promise<Response>;
|
|
303
|
+
|
|
304
|
+
function buildFetchCall(customFetch?: typeof globalThis.fetch): NextFn {
|
|
305
|
+
const fetchFn = customFetch ?? globalThis.fetch.bind(globalThis);
|
|
306
|
+
return async (ctx: RequestContext): Promise<Response> => {
|
|
307
|
+
const init: RequestInit = {
|
|
308
|
+
method: ctx.method,
|
|
309
|
+
headers: ctx.headers,
|
|
310
|
+
body: ctx.body,
|
|
311
|
+
signal: ctx.signal,
|
|
312
|
+
};
|
|
313
|
+
if (isReadableStream(ctx.body)) {
|
|
314
|
+
// Node's fetch requires duplex: "half" for stream bodies.
|
|
315
|
+
(init as { duplex?: "half" }).duplex = "half";
|
|
316
|
+
}
|
|
317
|
+
return fetchFn(ctx.url, init);
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function composeMiddleware(middlewares: MiddlewareFn[], core: NextFn): NextFn {
|
|
322
|
+
return middlewares.reduceRight<NextFn>((next, middleware) => (ctx) => middleware(ctx, next), core);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Helpers
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/** Parse a `Retry-After` header value into a delay in ms, or undefined when
|
|
330
|
+
* absent/unparseable. Integer seconds → ms; HTTP-date → ms from now, clamped
|
|
331
|
+
* to ≥ 0; anything else (garbage, negative) → undefined. */
|
|
332
|
+
export function parseRetryAfter(header: string | null): number | undefined {
|
|
333
|
+
if (!header) return undefined;
|
|
334
|
+
const trimmed = header.trim();
|
|
335
|
+
if (/^\d+$/.test(trimmed)) {
|
|
336
|
+
return Number(trimmed) * 1000;
|
|
337
|
+
}
|
|
338
|
+
const parsed = Date.parse(trimmed);
|
|
339
|
+
if (Number.isNaN(parsed)) return undefined;
|
|
340
|
+
return Math.max(0, parsed - Date.now());
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isAbortError(err: unknown): boolean {
|
|
344
|
+
return err instanceof Error && err.name === "AbortError";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function extractIssues(err: unknown): unknown[] {
|
|
348
|
+
if (err && typeof err === "object" && "errors" in err) {
|
|
349
|
+
return (err as { errors: unknown[] }).errors;
|
|
350
|
+
}
|
|
351
|
+
if (err && typeof err === "object" && "issues" in err) {
|
|
352
|
+
return (err as { issues: unknown[] }).issues;
|
|
353
|
+
}
|
|
354
|
+
return [err];
|
|
355
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { AppError } from "../core/errors.ts";
|
|
2
|
+
|
|
3
|
+
export interface RetryPolicyContext {
|
|
4
|
+
method: string;
|
|
5
|
+
headers?: HeadersInit;
|
|
6
|
+
/** value of merged retry.idempotent for this request */
|
|
7
|
+
idempotent?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type RetryPolicy = (error: AppError, attempt: number, ctx: RetryPolicyContext) => boolean;
|
|
11
|
+
|
|
12
|
+
export const RETRIABLE_KINDS: ReadonlySet<AppError["kind"]> = new Set(["network", "timeout", "retryable_status"]);
|
|
13
|
+
|
|
14
|
+
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The default retry policy (decision D3). An attempt is retried iff all hold:
|
|
18
|
+
* 1. attempts so far < maxRetries + 1 (enforced by the retry loop bounds)
|
|
19
|
+
* 2. error `kind` ∈ {network, timeout, retryable_status}
|
|
20
|
+
* 3. method is idempotent (GET HEAD OPTIONS PUT DELETE TRACE) OR
|
|
21
|
+
* `ctx.idempotent === true` OR the request has an `Idempotency-Key` header
|
|
22
|
+
* 4. user `retryWhen` (consulted separately via `shouldRetry`) returns true
|
|
23
|
+
* 5. the request is not cancelled / past its deadline (handled elsewhere)
|
|
24
|
+
*/
|
|
25
|
+
export function defaultRetryPolicy(error: AppError, _attempt: number, ctx: RetryPolicyContext): boolean {
|
|
26
|
+
// Rule 2 — the error kind must be transient.
|
|
27
|
+
if (!RETRIABLE_KINDS.has(error.kind)) return false;
|
|
28
|
+
// Rule 3 — the request must be safe to repeat (idempotent by method or opt-in).
|
|
29
|
+
if (IDEMPOTENT_METHODS.has(ctx.method.toUpperCase())) return true;
|
|
30
|
+
if (ctx.idempotent) return true;
|
|
31
|
+
if (ctx.headers && new Headers(ctx.headers).has("idempotency-key")) return true;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Combined retry gate used by both call sites so rule ordering lives in one
|
|
37
|
+
* place: the default policy runs first, then the user's `retryWhen` is
|
|
38
|
+
* consulted. `retryWhen` can only veto a retry, never force one.
|
|
39
|
+
*/
|
|
40
|
+
export function shouldRetry(
|
|
41
|
+
error: AppError,
|
|
42
|
+
attempt: number,
|
|
43
|
+
ctx: RetryPolicyContext,
|
|
44
|
+
retryWhen?: (error: AppError, attempt: number) => boolean,
|
|
45
|
+
): boolean {
|
|
46
|
+
if (!defaultRetryPolicy(error, attempt, ctx)) return false;
|
|
47
|
+
if (retryWhen && !retryWhen(error, attempt)) return false;
|
|
48
|
+
return true;
|
|
49
|
+
}
|