@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,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
+ }