@zap-studio/retry 1.2.0 → 2.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/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.0.0]
8
+
9
+ ### Added
10
+
11
+ Native OpenTelemetry support. Unlike `fetch`, `webhooks`, and `permit`, this package never creates its own span — a retry loop wraps someone else's operation, so each decision is recorded as an event (`retry.scheduled`/`retry.exhausted`) on whatever span is already active, plus a `retry.attempts` counter tagged by outcome. See [OpenTelemetry](https://www.zapstudio.dev/retry/opentelemetry).
12
+
13
+ ### Changed
14
+
15
+ **Breaking:** `@opentelemetry/api` is now a required peer dependency. It's tiny, side-effect-free, and a no-op until an app registers a real SDK, so nothing changes at runtime for consumers who don't set one up — but the package won't resolve without it installed: `npm install @opentelemetry/api`.
16
+
17
+ ## [1.2.1]
18
+
19
+ ### Changed
20
+
21
+ `@zap-studio/logger` is now an optional peer dependency instead of a regular dependency. Every import from it is type-only (`import type { Logger }`), so it was never pulled in at runtime — pass any object matching the `Logger` shape (including `pino`) with no install required. Existing consumers of `logger?: Logger` are unaffected.
22
+
7
23
  ## [1.2.0]
8
24
 
9
25
  ### Added
package/README.md CHANGED
@@ -4,6 +4,16 @@ Composable retry policy primitives for HTTP clients and async workflows.
4
4
 
5
5
  Full documentation: [zapstudio.dev/retry](https://www.zapstudio.dev/retry)
6
6
 
7
+ ## Motivation
8
+
9
+ A hand-written retry loop is usually a `for` loop with `setTimeout`, and it is easy to get wrong in ways that only show up under load. Without jitter, every client that lost connection to a service retries at the exact same moment once it comes back, causing a new spike right when the service is trying to recover.
10
+
11
+ Without cancellation, a retry loop can keep running — and keep hitting the network — after the result is no longer needed, for example after a user navigates away.
12
+
13
+ `@zap-studio/retry` gives you this behavior as a built-in option, instead of something you write from scratch. `exponentialBackoff` and `linearBackoff` support jitter (`"full"` or `"equal"`, the same strategies AWS recommends) through the `jitter` option — turn it on and delays are randomized instead of fixed.
14
+
15
+ Every retry loop also accepts an `AbortSignal`, checked before each attempt and while waiting between attempts, so an abort stops the next attempt or delay from starting; it does not interrupt an attempt that is already running. You get retry policies as values you configure once and reuse, instead of logic you have to get right from scratch in every project.
16
+
7
17
  ## Installation
8
18
 
9
19
  ```bash
@@ -185,6 +195,28 @@ await runRetryPolicy(policy, execute, { logger });
185
195
 
186
196
  Each retry decision logs at `debug` (attempt, delay, reason), exhaustion logs at `warn`, and cancellation logs at `debug`.
187
197
 
198
+ ## OpenTelemetry
199
+
200
+ `@opentelemetry/api` is a required peer dependency — tiny, side-effect-free, and a no-op until an app registers a real SDK, so installing it costs nothing at runtime for consumers who never set one up.
201
+
202
+ Unlike `fetch`, `webhooks`, and `permit`, this package never creates its own span — a retry loop wraps someone else's operation, so each decision is recorded as an **event** on whatever span is already active (e.g. a caller's `fetch` span), plus a `retry.attempts` counter tagged by outcome:
203
+
204
+ ```bash
205
+ npm install @opentelemetry/api
206
+ ```
207
+
208
+ ```ts
209
+ import { exponentialBackoff, runRetryPolicy } from "@zap-studio/retry";
210
+
211
+ const policy = exponentialBackoff({ maxAttempts: 5, baseDelayMs: 100 });
212
+
213
+ // If a span is active when this runs (e.g. inside a caller's own span, or
214
+ // nested inside a @zap-studio/fetch call), each retry adds a
215
+ // "retry.scheduled" or "retry.exhausted" event to it. If not, it's a no-op
216
+ // — no wiring required either way.
217
+ await runRetryPolicy(policy, execute);
218
+ ```
219
+
188
220
  ## Runtime Support
189
221
 
190
222
  | Runtime | Minimum version |
@@ -0,0 +1,381 @@
1
+ import { AbortError, RetryError } from "./errors.js";
2
+ import { metrics, trace } from "@opentelemetry/api";
3
+ //#region package.json
4
+ var name = "@zap-studio/retry";
5
+ var version = "2.0.0";
6
+ //#endregion
7
+ //#region src/_otel.ts
8
+ /**
9
+ * Internal OpenTelemetry wiring for the retry package: the attempts counter.
10
+ * Kept out of `base-policy.ts` so retry orchestration doesn't get tangled
11
+ * with metrics concerns. Span events are added directly to whatever span is
12
+ * already active (e.g. a caller's fetch span), so this package never
13
+ * creates its own tracer.
14
+ *
15
+ * @module @zap-studio/retry/otel
16
+ */
17
+ /**
18
+ * Records one retry decision, tagged with `retry.decision: "retry" |
19
+ * "exhausted"`.
20
+ *
21
+ * Resolves the meter and counter fresh on every call instead of caching
22
+ * them at module scope: unlike `trace.getTracer()`, `metrics.getMeter()`
23
+ * has no proxy indirection — a reference grabbed before an app registers
24
+ * its `MeterProvider` (the common case, since ESM imports resolve before
25
+ * the importing module's own SDK-bootstrap code runs) would stay a no-op
26
+ * forever. Repeated `createCounter` calls with the same name are cheap and
27
+ * idempotent, so this costs nothing meaningful.
28
+ */
29
+ const recordRetryAttempt = (decision) => {
30
+ metrics.getMeter(name, version).createCounter("retry.attempts", { description: "Number of retry decisions made, tagged by outcome." }).add(1, { "retry.decision": decision });
31
+ };
32
+ //#endregion
33
+ //#region src/base-policy.ts
34
+ /**
35
+ * Retry runner base class and shared orchestration implementation.
36
+ *
37
+ * @module @zap-studio/retry/base-policy
38
+ */
39
+ /**
40
+ * Awaits a timer-based delay, unless `delayMs` is non-positive.
41
+ *
42
+ * @param delayMs - Milliseconds to wait before resolving.
43
+ * @returns Promise that resolves when the delay completes.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * import { defaultSleep } from "@zap-studio/retry";
48
+ *
49
+ * await defaultSleep(250); // waits 250ms
50
+ * ```
51
+ */
52
+ const defaultSleep = async (delayMs) => {
53
+ if (delayMs <= 0) return;
54
+ await new Promise((resolve) => {
55
+ setTimeout(resolve, delayMs);
56
+ });
57
+ };
58
+ /**
59
+ * Normalizes an abort `reason` into an `AbortError`.
60
+ */
61
+ const toAbortError = (reason) => {
62
+ if (reason instanceof AbortError) return reason;
63
+ if (reason instanceof Error) return new AbortError(reason.message, { cause: reason });
64
+ if (typeof reason === "string" && reason.length > 0) return new AbortError(reason);
65
+ if (reason === void 0) return new AbortError("Retry aborted.");
66
+ try {
67
+ return new AbortError(`Retry aborted: ${JSON.stringify(reason)}`);
68
+ } catch {
69
+ return new AbortError("Retry aborted.");
70
+ }
71
+ };
72
+ /**
73
+ * Throws when the provided abort signal is already aborted.
74
+ *
75
+ * @param signal - Optional abort signal to inspect.
76
+ * @param logger - Optional logger; logs the abort at `debug` before throwing.
77
+ * @throws {AbortError} When the signal is aborted.
78
+ */
79
+ const throwIfAborted = (signal, logger) => {
80
+ if (signal?.aborted !== true) return;
81
+ logger?.debug("retry aborted", { reason: signal.reason });
82
+ throw toAbortError(signal.reason);
83
+ };
84
+ /**
85
+ * Waits for delay sleep while observing cancellation through an abort signal.
86
+ *
87
+ * @param sleep - Sleep function used to await `delayMs`.
88
+ * @param delayMs - Delay duration in milliseconds.
89
+ * @param signal - Abort signal to observe while waiting.
90
+ * @returns Promise that resolves when delay finishes.
91
+ * @throws {AbortError} When the signal aborts before or during wait.
92
+ */
93
+ const sleepWithAbortSignal = async (sleep, delayMs, signal) => {
94
+ if (signal.aborted) throw toAbortError(signal.reason);
95
+ let onAbort;
96
+ try {
97
+ await Promise.race([sleep(delayMs), new Promise((_resolve, reject) => {
98
+ onAbort = () => {
99
+ reject(toAbortError(signal.reason));
100
+ };
101
+ signal.addEventListener("abort", onAbort, { once: true });
102
+ })]);
103
+ } finally {
104
+ if (onAbort) signal.removeEventListener("abort", onAbort);
105
+ }
106
+ };
107
+ /**
108
+ * Logs a `next(...)` decision: `debug` when retrying, `warn` when exhausted.
109
+ * Shared by both the throw-mode and non-throw retry loops.
110
+ */
111
+ const logRetryDecision = (logger, attempt, decision, error) => {
112
+ if (decision.shouldRetry) {
113
+ logger?.debug("retry scheduled", {
114
+ attempt,
115
+ delayMs: decision.delayMs,
116
+ reason: decision.reason
117
+ });
118
+ trace.getActiveSpan()?.addEvent("retry.scheduled", {
119
+ attempt,
120
+ "retry.delay_ms": decision.delayMs,
121
+ "retry.reason": decision.reason ?? ""
122
+ });
123
+ recordRetryAttempt("retry");
124
+ return;
125
+ }
126
+ logger?.warn("retry policy exhausted", {
127
+ attempts: attempt,
128
+ error,
129
+ reason: decision.reason
130
+ });
131
+ trace.getActiveSpan()?.addEvent("retry.exhausted", {
132
+ attempt,
133
+ "retry.reason": decision.reason ?? ""
134
+ });
135
+ recordRetryAttempt("exhausted");
136
+ };
137
+ /**
138
+ * Runs the throw-mode retry loop: throws `RetryError` on exhaustion and
139
+ * `AbortError` when `signal` aborts.
140
+ *
141
+ * @param policy - Resolved retry policy providing `next` and `onExhausted`.
142
+ * @param execute - Async work callback per attempt.
143
+ * @param sleep - Delay function between retries.
144
+ * @param signal - Optional cancel signal.
145
+ * @param logger - Optional logger; logs each retry decision at `debug` and
146
+ * exhaustion at `warn`.
147
+ * @returns Resolves to the first successful return value.
148
+ * @throws {RetryError} When retries are exhausted and `onExhausted` returns
149
+ * the terminal error.
150
+ * @throws {AbortError} When `signal` is already aborted or aborts while waiting.
151
+ * @throws {Error} Any error thrown by `next`, `onExhausted`, or `sleep`. Also
152
+ * rethrows the original caught value immediately, bypassing retry, when
153
+ * `policy.isKnownError` rejects it as outside this policy's error domain.
154
+ */
155
+ const runThrowMode = async (policy, execute, sleep, signal, logger) => {
156
+ let attempt = 1;
157
+ while (true) {
158
+ throwIfAborted(signal, logger);
159
+ try {
160
+ return await execute(attempt);
161
+ } catch (error) {
162
+ throwIfAborted(signal, logger);
163
+ if (!policy.isKnownError(error)) throw error;
164
+ const decision = policy.next({
165
+ attempt,
166
+ error
167
+ });
168
+ logRetryDecision(logger, attempt, decision, error);
169
+ if (!decision.shouldRetry) throw policy.onExhausted({
170
+ attempts: attempt,
171
+ error
172
+ });
173
+ if (decision.delayMs > 0) await (signal === void 0 ? sleep(decision.delayMs) : sleepWithAbortSignal(sleep, decision.delayMs, signal));
174
+ attempt += 1;
175
+ }
176
+ }
177
+ };
178
+ /**
179
+ * When `signal` is already aborted, builds the terminal `{ ok: false }` object
180
+ * with a normalized `AbortError` on `error`.
181
+ *
182
+ * @param signal - Optional abort signal; only acts when `aborted` is set.
183
+ * @param attempts - Number of finished attempts to report in the result.
184
+ * @param logger - Optional logger; logs the abort at `debug`.
185
+ * @returns Failure result or `undefined` if not aborted.
186
+ */
187
+ const buildAbortResult = (signal, attempts, logger) => {
188
+ if (signal?.aborted !== true) return;
189
+ logger?.debug("retry aborted", { reason: signal.reason });
190
+ return {
191
+ attempts,
192
+ error: toAbortError(signal.reason),
193
+ ok: false
194
+ };
195
+ };
196
+ /**
197
+ * Runs one `execute(attempt)` call and returns either a success value or a
198
+ * captured error without rethrowing.
199
+ *
200
+ * @param execute - User work callback.
201
+ * @param attempt - One-based attempt number passed to `execute`.
202
+ * @returns A tagged success with `value` or a tagged failure with `error`.
203
+ */
204
+ const runAttempt = async (execute, attempt) => {
205
+ try {
206
+ return {
207
+ ok: true,
208
+ value: await execute(attempt)
209
+ };
210
+ } catch (error) {
211
+ return {
212
+ error,
213
+ ok: false
214
+ };
215
+ }
216
+ };
217
+ /**
218
+ * Awaits inter-attempt delay in result mode, mapping an abort during wait to
219
+ * a terminal result instead of throwing when `throwOnExhausted` is false.
220
+ *
221
+ * @param sleep - Custom or default sleep implementation.
222
+ * @param delayMs - Milliseconds to wait.
223
+ * @param signal - If set, `sleep` is raced with the abort signal.
224
+ * @param attempts - Attempt count to attach if the wait ends in abort.
225
+ * @param logger - Optional logger; logs an abort ending the wait at `debug`.
226
+ * @returns A terminal result when canceled during the wait, otherwise
227
+ * `undefined`.
228
+ * @throws {Error} The underlying `sleep` rejection when it is not an abort.
229
+ */
230
+ const waitForDelay = async (sleep, delayMs, signal, attempts, logger) => {
231
+ if (signal === void 0) {
232
+ await sleep(delayMs);
233
+ return;
234
+ }
235
+ try {
236
+ await sleepWithAbortSignal(sleep, delayMs, signal);
237
+ return;
238
+ } catch (error) {
239
+ const aborted = buildAbortResult(signal, attempts, logger);
240
+ if (aborted !== void 0) return aborted;
241
+ throw error;
242
+ }
243
+ };
244
+ /**
245
+ * After a failed attempt, applies abort rules, `next`, optional delay, and
246
+ * either returns a terminal `RetryRunResult` or `undefined` to continue.
247
+ *
248
+ * @param policy - Resolved retry policy hooks (`next`, `onExhausted`).
249
+ * @param params - Failure context for the current attempt.
250
+ * @param params.attempt - Current attempt number.
251
+ * @param params.error - Error thrown by the attempt.
252
+ * @param params.sleep - Delay function between retries.
253
+ * @param params.signal - Optional abort signal.
254
+ * @param params.logger - Optional logger; logs each retry decision at
255
+ * `debug`, exhaustion at `warn`, and cancellation at `debug`.
256
+ * @returns Terminal non-throw result if the loop should stop, otherwise
257
+ * `undefined` to schedule another attempt.
258
+ * @throws {Error} Any error thrown by `next`, `onExhausted`, or a custom `sleep` when
259
+ * the error is not an abort.
260
+ */
261
+ const handleFailure = async (policy, params) => {
262
+ const { attempt, error, sleep, signal, logger } = params;
263
+ const abortResult = buildAbortResult(signal, attempt, logger);
264
+ if (abortResult !== void 0) return abortResult;
265
+ const decision = policy.next({
266
+ attempt,
267
+ error
268
+ });
269
+ logRetryDecision(logger, attempt, decision, error);
270
+ if (!decision.shouldRetry) return {
271
+ attempts: attempt,
272
+ error: policy.onExhausted({
273
+ attempts: attempt,
274
+ error
275
+ }),
276
+ ok: false
277
+ };
278
+ if (decision.delayMs > 0) {
279
+ const delayAbortResult = await waitForDelay(sleep, decision.delayMs, signal, attempt, logger);
280
+ if (delayAbortResult !== void 0) return delayAbortResult;
281
+ }
282
+ };
283
+ /**
284
+ * Runs the non-throw retry loop, returning
285
+ * `RetryRunResult`.
286
+ *
287
+ * @param policy - Resolved retry policy providing `next` and `onExhausted`.
288
+ * @param execute - Async work callback per attempt.
289
+ * @param sleep - Delay function between retries.
290
+ * @param signal - Optional cancel signal.
291
+ * @param logger - Optional logger; logs each retry decision at `debug`,
292
+ * exhaustion at `warn`, and cancellation at `debug`.
293
+ * @returns Terminal success or failure object. When `policy.isKnownError`
294
+ * rejects a caught value as outside this policy's error domain, the
295
+ * original value is wrapped in a `RetryError` and returned on
296
+ * `result.error` immediately, bypassing retry — never thrown.
297
+ * @throws {Error} Any error thrown by `next`, `onExhausted`, or a non-abort `sleep`
298
+ * failure.
299
+ */
300
+ const runResultMode = async (policy, execute, sleep, signal, logger) => {
301
+ let attempt = 1;
302
+ while (true) {
303
+ const abortResult = buildAbortResult(signal, Math.max(0, attempt - 1), logger);
304
+ if (abortResult !== void 0) return abortResult;
305
+ const execution = await runAttempt(execute, attempt);
306
+ if (execution.ok) return {
307
+ ok: true,
308
+ value: execution.value
309
+ };
310
+ const attemptAbortResult = buildAbortResult(signal, attempt, logger);
311
+ if (attemptAbortResult !== void 0) return attemptAbortResult;
312
+ if (!policy.isKnownError(execution.error)) return {
313
+ attempts: attempt,
314
+ error: new RetryError("Retry policy encountered an unknown error.", {
315
+ attempts: attempt,
316
+ lastError: execution.error
317
+ }),
318
+ ok: false
319
+ };
320
+ const failure = await handleFailure(policy, {
321
+ attempt,
322
+ error: execution.error,
323
+ logger,
324
+ signal,
325
+ sleep
326
+ });
327
+ if (failure !== void 0) return failure;
328
+ attempt += 1;
329
+ }
330
+ };
331
+ /**
332
+ * Default `onExhausted` used when a policy omits it: wraps the exhaustion
333
+ * context in a generic `RetryError`.
334
+ */
335
+ const defaultOnExhausted = (input) => new RetryError("Retry policy exhausted all attempts.", {
336
+ attempts: input.attempts,
337
+ lastData: input.data,
338
+ lastError: input.error
339
+ });
340
+ /**
341
+ * Default `isKnownError` used when a policy omits it: accepts any `Error`
342
+ * instance and rejects everything else.
343
+ */
344
+ const defaultIsKnownError = (error) => error instanceof Error;
345
+ /**
346
+ * Runs retry orchestration in non-throw mode.
347
+ *
348
+ * When `throwOnExhausted` is `false`, returns a discriminated result union.
349
+ *
350
+ * @param policy - Retry policy: `next` is required, `onExhausted` and
351
+ * `isKnownError` fall back to their defaults when omitted.
352
+ * @param execute - Async function to execute per attempt.
353
+ * @param options - Runner settings.
354
+ * @returns Success value or terminal result object based on option mode.
355
+ * @throws {Error} Any error thrown by `next`, by `onExhausted`, or by a custom `sleep`
356
+ * function. When `throwOnExhausted` is `false`, exhaustion itself is returned
357
+ * as `{ ok: false }` instead of thrown.
358
+ * Cancellation is returned as `{ ok: false, error: AbortError }` in non-throw
359
+ * mode. A value rejected by `policy.isKnownError` is wrapped in a
360
+ * `RetryError` and returned the same way in non-throw mode; in throw mode
361
+ * it is rethrown as-is.
362
+ *
363
+ * @example
364
+ * const result = await runRetryPolicy(policy, doWork, { throwOnExhausted: false });
365
+ * if (!result.ok) console.error(result.error);
366
+ */
367
+ async function runRetryPolicy(policy, execute, options = {}) {
368
+ const sleep = options.sleep ?? defaultSleep;
369
+ const { signal, logger } = options;
370
+ const resolvedPolicy = {
371
+ isKnownError: (error) => policy.isKnownError ? policy.isKnownError(error) : defaultIsKnownError(error),
372
+ next: (input) => policy.next(input),
373
+ onExhausted: (input) => policy.onExhausted ? policy.onExhausted(input) : defaultOnExhausted(input)
374
+ };
375
+ if (options.throwOnExhausted === false) return await runResultMode(resolvedPolicy, execute, sleep, signal, logger);
376
+ return await runThrowMode(resolvedPolicy, execute, sleep, signal, logger);
377
+ }
378
+ //#endregion
379
+ export { runRetryPolicy as n, defaultSleep as t };
380
+
381
+ //# sourceMappingURL=base-policy-CiDqMVSD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base-policy-CiDqMVSD.js","names":["pkg.name","pkg.version"],"sources":["../package.json","../src/_otel.ts","../src/base-policy.ts"],"sourcesContent":["","/**\n * Internal OpenTelemetry wiring for the retry package: the attempts counter.\n * Kept out of `base-policy.ts` so retry orchestration doesn't get tangled\n * with metrics concerns. Span events are added directly to whatever span is\n * already active (e.g. a caller's fetch span), so this package never\n * creates its own tracer.\n *\n * @module @zap-studio/retry/otel\n */\n\nimport { metrics } from \"@opentelemetry/api\";\n\nimport pkg from \"../package.json\" with { type: \"json\" };\n\n/**\n * Records one retry decision, tagged with `retry.decision: \"retry\" |\n * \"exhausted\"`.\n *\n * Resolves the meter and counter fresh on every call instead of caching\n * them at module scope: unlike `trace.getTracer()`, `metrics.getMeter()`\n * has no proxy indirection — a reference grabbed before an app registers\n * its `MeterProvider` (the common case, since ESM imports resolve before\n * the importing module's own SDK-bootstrap code runs) would stay a no-op\n * forever. Repeated `createCounter` calls with the same name are cheap and\n * idempotent, so this costs nothing meaningful.\n */\nexport const recordRetryAttempt = (decision: \"exhausted\" | \"retry\"): void => {\n metrics\n .getMeter(pkg.name, pkg.version)\n .createCounter(\"retry.attempts\", {\n description: \"Number of retry decisions made, tagged by outcome.\",\n })\n .add(1, { \"retry.decision\": decision });\n};\n","/**\n * Retry runner base class and shared orchestration implementation.\n *\n * @module @zap-studio/retry/base-policy\n */\n\nimport { trace } from \"@opentelemetry/api\";\nimport type { Logger } from \"@zap-studio/logger\";\n\nimport { recordRetryAttempt } from \"./_otel.js\";\nimport { AbortError, RetryError } from \"./errors.js\";\nimport type {\n ResolvedRetryPolicy,\n RetryDecision,\n RetryExhaustedInput,\n RetryPolicy,\n RetryRunOptions,\n RetryRunResult,\n} from \"./types.js\";\n\n/**\n * Awaits a timer-based delay, unless `delayMs` is non-positive.\n *\n * @param delayMs - Milliseconds to wait before resolving.\n * @returns Promise that resolves when the delay completes.\n *\n * @example\n * ```ts\n * import { defaultSleep } from \"@zap-studio/retry\";\n *\n * await defaultSleep(250); // waits 250ms\n * ```\n */\nexport const defaultSleep = async (delayMs: number): Promise<void> => {\n if (delayMs <= 0) {\n return;\n }\n\n // oxlint-disable-next-line promise/avoid-new -- Timer sleep requires adapting callback API to a promise.\n await new Promise<void>((resolve) => {\n setTimeout(resolve, delayMs);\n });\n};\n\n/**\n * Normalizes an abort `reason` into an `AbortError`.\n */\nconst toAbortError = (reason: unknown): AbortError => {\n if (reason instanceof AbortError) {\n return reason;\n }\n\n if (reason instanceof Error) {\n return new AbortError(reason.message, { cause: reason });\n }\n\n if (typeof reason === \"string\" && reason.length > 0) {\n return new AbortError(reason);\n }\n\n if (reason === undefined) {\n return new AbortError(\"Retry aborted.\");\n }\n\n try {\n return new AbortError(`Retry aborted: ${JSON.stringify(reason)}`);\n } catch {\n return new AbortError(\"Retry aborted.\");\n }\n};\n\n/**\n * Throws when the provided abort signal is already aborted.\n *\n * @param signal - Optional abort signal to inspect.\n * @param logger - Optional logger; logs the abort at `debug` before throwing.\n * @throws {AbortError} When the signal is aborted.\n */\nconst throwIfAborted = (signal?: AbortSignal, logger?: Logger): void => {\n if (signal?.aborted !== true) {\n return;\n }\n\n logger?.debug(\"retry aborted\", { reason: signal.reason });\n throw toAbortError(signal.reason);\n};\n\n/**\n * Waits for delay sleep while observing cancellation through an abort signal.\n *\n * @param sleep - Sleep function used to await `delayMs`.\n * @param delayMs - Delay duration in milliseconds.\n * @param signal - Abort signal to observe while waiting.\n * @returns Promise that resolves when delay finishes.\n * @throws {AbortError} When the signal aborts before or during wait.\n */\nconst sleepWithAbortSignal = async (\n sleep: (delayMs: number) => Promise<void>,\n delayMs: number,\n signal: AbortSignal\n): Promise<void> => {\n if (signal.aborted) {\n throw toAbortError(signal.reason);\n }\n\n let onAbort: (() => void) | undefined;\n\n try {\n await Promise.race([\n sleep(delayMs),\n // oxlint-disable-next-line promise/avoid-new -- AbortSignal callback is adapted into the race promise.\n new Promise<never>((_resolve, reject) => {\n onAbort = (): void => {\n reject(toAbortError(signal.reason));\n };\n\n signal.addEventListener(\"abort\", onAbort, { once: true });\n }),\n ]);\n } finally {\n if (onAbort) {\n signal.removeEventListener(\"abort\", onAbort);\n }\n }\n};\n\n/**\n * Logs a `next(...)` decision: `debug` when retrying, `warn` when exhausted.\n * Shared by both the throw-mode and non-throw retry loops.\n */\nconst logRetryDecision = (\n logger: Logger | undefined,\n attempt: number,\n decision: RetryDecision,\n error: unknown\n): void => {\n if (decision.shouldRetry) {\n logger?.debug(\"retry scheduled\", {\n attempt,\n delayMs: decision.delayMs,\n reason: decision.reason,\n });\n trace.getActiveSpan()?.addEvent(\"retry.scheduled\", {\n attempt,\n \"retry.delay_ms\": decision.delayMs,\n \"retry.reason\": decision.reason ?? \"\",\n });\n recordRetryAttempt(\"retry\");\n return;\n }\n\n logger?.warn(\"retry policy exhausted\", {\n attempts: attempt,\n error,\n reason: decision.reason,\n });\n trace.getActiveSpan()?.addEvent(\"retry.exhausted\", {\n attempt,\n \"retry.reason\": decision.reason ?? \"\",\n });\n recordRetryAttempt(\"exhausted\");\n};\n\n/**\n * Runs the throw-mode retry loop: throws `RetryError` on exhaustion and\n * `AbortError` when `signal` aborts.\n *\n * @param policy - Resolved retry policy providing `next` and `onExhausted`.\n * @param execute - Async work callback per attempt.\n * @param sleep - Delay function between retries.\n * @param signal - Optional cancel signal.\n * @param logger - Optional logger; logs each retry decision at `debug` and\n * exhaustion at `warn`.\n * @returns Resolves to the first successful return value.\n * @throws {RetryError} When retries are exhausted and `onExhausted` returns\n * the terminal error.\n * @throws {AbortError} When `signal` is already aborted or aborts while waiting.\n * @throws {Error} Any error thrown by `next`, `onExhausted`, or `sleep`. Also\n * rethrows the original caught value immediately, bypassing retry, when\n * `policy.isKnownError` rejects it as outside this policy's error domain.\n */\nconst runThrowMode = async <T, TError extends Error, TData>(\n policy: ResolvedRetryPolicy<TError, TData>,\n execute: (attempt: number) => Promise<T>,\n sleep: (delayMs: number) => Promise<void>,\n signal?: AbortSignal,\n logger?: Logger\n): Promise<T> => {\n let attempt = 1;\n\n while (true) {\n throwIfAborted(signal, logger);\n\n try {\n // oxlint-disable-next-line no-await-in-loop -- Retry attempts must run sequentially.\n return await execute(attempt);\n } catch (error) {\n throwIfAborted(signal, logger);\n\n if (!policy.isKnownError(error)) {\n throw error;\n }\n\n const decision = policy.next({\n attempt,\n error,\n });\n logRetryDecision(logger, attempt, decision, error);\n\n if (!decision.shouldRetry) {\n throw policy.onExhausted({\n attempts: attempt,\n error,\n });\n }\n\n if (decision.delayMs > 0) {\n // oxlint-disable-next-line no-await-in-loop -- Delay belongs between sequential retry attempts.\n await (signal === undefined\n ? sleep(decision.delayMs)\n : sleepWithAbortSignal(sleep, decision.delayMs, signal));\n }\n\n attempt += 1;\n }\n }\n};\n\n/**\n * When `signal` is already aborted, builds the terminal `{ ok: false }` object\n * with a normalized `AbortError` on `error`.\n *\n * @param signal - Optional abort signal; only acts when `aborted` is set.\n * @param attempts - Number of finished attempts to report in the result.\n * @param logger - Optional logger; logs the abort at `debug`.\n * @returns Failure result or `undefined` if not aborted.\n */\nconst buildAbortResult = (\n signal: AbortSignal | undefined,\n attempts: number,\n logger?: Logger\n): RetryRunResult<never> | undefined => {\n if (signal?.aborted !== true) {\n return undefined;\n }\n\n logger?.debug(\"retry aborted\", { reason: signal.reason });\n\n return {\n attempts,\n error: toAbortError(signal.reason),\n ok: false,\n };\n};\n\n/**\n * Runs one `execute(attempt)` call and returns either a success value or a\n * captured error without rethrowing.\n *\n * @param execute - User work callback.\n * @param attempt - One-based attempt number passed to `execute`.\n * @returns A tagged success with `value` or a tagged failure with `error`.\n */\nconst runAttempt = async <T>(\n execute: (attempt: number) => Promise<T>,\n attempt: number\n): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> => {\n try {\n return {\n ok: true,\n value: await execute(attempt),\n };\n } catch (error) {\n return {\n error,\n ok: false,\n };\n }\n};\n\n/**\n * Awaits inter-attempt delay in result mode, mapping an abort during wait to\n * a terminal result instead of throwing when `throwOnExhausted` is false.\n *\n * @param sleep - Custom or default sleep implementation.\n * @param delayMs - Milliseconds to wait.\n * @param signal - If set, `sleep` is raced with the abort signal.\n * @param attempts - Attempt count to attach if the wait ends in abort.\n * @param logger - Optional logger; logs an abort ending the wait at `debug`.\n * @returns A terminal result when canceled during the wait, otherwise\n * `undefined`.\n * @throws {Error} The underlying `sleep` rejection when it is not an abort.\n */\nconst waitForDelay = async (\n sleep: (delayMs: number) => Promise<void>,\n delayMs: number,\n signal: AbortSignal | undefined,\n attempts: number,\n logger?: Logger\n): Promise<RetryRunResult<never> | undefined> => {\n if (signal === undefined) {\n await sleep(delayMs);\n return undefined;\n }\n\n try {\n await sleepWithAbortSignal(sleep, delayMs, signal);\n return undefined;\n } catch (error) {\n const aborted = buildAbortResult(signal, attempts, logger);\n if (aborted !== undefined) {\n return aborted;\n }\n throw error;\n }\n};\n\n/**\n * After a failed attempt, applies abort rules, `next`, optional delay, and\n * either returns a terminal `RetryRunResult` or `undefined` to continue.\n *\n * @param policy - Resolved retry policy hooks (`next`, `onExhausted`).\n * @param params - Failure context for the current attempt.\n * @param params.attempt - Current attempt number.\n * @param params.error - Error thrown by the attempt.\n * @param params.sleep - Delay function between retries.\n * @param params.signal - Optional abort signal.\n * @param params.logger - Optional logger; logs each retry decision at\n * `debug`, exhaustion at `warn`, and cancellation at `debug`.\n * @returns Terminal non-throw result if the loop should stop, otherwise\n * `undefined` to schedule another attempt.\n * @throws {Error} Any error thrown by `next`, `onExhausted`, or a custom `sleep` when\n * the error is not an abort.\n */\nconst handleFailure = async <TError extends Error, TData>(\n policy: ResolvedRetryPolicy<TError, TData>,\n params: {\n attempt: number;\n error: TError;\n sleep: (delayMs: number) => Promise<void>;\n signal: AbortSignal | undefined;\n logger: Logger | undefined;\n }\n): Promise<RetryRunResult<never> | undefined> => {\n const { attempt, error, sleep, signal, logger } = params;\n const abortResult = buildAbortResult(signal, attempt, logger);\n if (abortResult !== undefined) {\n return abortResult;\n }\n\n const decision = policy.next({\n attempt,\n error,\n });\n logRetryDecision(logger, attempt, decision, error);\n\n if (!decision.shouldRetry) {\n const terminalError = policy.onExhausted({\n attempts: attempt,\n error,\n });\n\n return {\n attempts: attempt,\n error: terminalError,\n ok: false,\n };\n }\n\n if (decision.delayMs > 0) {\n const delayAbortResult = await waitForDelay(\n sleep,\n decision.delayMs,\n signal,\n attempt,\n logger\n );\n if (delayAbortResult !== undefined) {\n return delayAbortResult;\n }\n }\n\n return undefined;\n};\n\n/**\n * Runs the non-throw retry loop, returning\n * `RetryRunResult`.\n *\n * @param policy - Resolved retry policy providing `next` and `onExhausted`.\n * @param execute - Async work callback per attempt.\n * @param sleep - Delay function between retries.\n * @param signal - Optional cancel signal.\n * @param logger - Optional logger; logs each retry decision at `debug`,\n * exhaustion at `warn`, and cancellation at `debug`.\n * @returns Terminal success or failure object. When `policy.isKnownError`\n * rejects a caught value as outside this policy's error domain, the\n * original value is wrapped in a `RetryError` and returned on\n * `result.error` immediately, bypassing retry — never thrown.\n * @throws {Error} Any error thrown by `next`, `onExhausted`, or a non-abort `sleep`\n * failure.\n */\nconst runResultMode = async <T, TError extends Error, TData>(\n policy: ResolvedRetryPolicy<TError, TData>,\n execute: (attempt: number) => Promise<T>,\n sleep: (delayMs: number) => Promise<void>,\n signal?: AbortSignal,\n logger?: Logger\n): Promise<RetryRunResult<T>> => {\n let attempt = 1;\n\n while (true) {\n const abortResult = buildAbortResult(\n signal,\n Math.max(0, attempt - 1),\n logger\n );\n if (abortResult !== undefined) {\n return abortResult;\n }\n\n // oxlint-disable-next-line no-await-in-loop -- Retry attempts must run sequentially.\n const execution = await runAttempt(execute, attempt);\n if (execution.ok) {\n return { ok: true, value: execution.value };\n }\n\n const attemptAbortResult = buildAbortResult(signal, attempt, logger);\n if (attemptAbortResult !== undefined) {\n return attemptAbortResult;\n }\n\n if (!policy.isKnownError(execution.error)) {\n return {\n attempts: attempt,\n error: new RetryError(\"Retry policy encountered an unknown error.\", {\n attempts: attempt,\n lastError: execution.error,\n }),\n ok: false,\n };\n }\n\n // oxlint-disable-next-line no-await-in-loop -- Failure handling belongs to the current sequential attempt.\n const failure = await handleFailure(policy, {\n attempt,\n error: execution.error,\n logger,\n signal,\n sleep,\n });\n if (failure !== undefined) {\n return failure;\n }\n\n attempt += 1;\n }\n};\n\n/**\n * Default `onExhausted` used when a policy omits it: wraps the exhaustion\n * context in a generic `RetryError`.\n */\nconst defaultOnExhausted = <TError extends Error, TData>(\n input: RetryExhaustedInput<TError, TData>\n): RetryError =>\n new RetryError(\"Retry policy exhausted all attempts.\", {\n attempts: input.attempts,\n lastData: input.data,\n lastError: input.error,\n });\n\n/**\n * Default `isKnownError` used when a policy omits it: accepts any `Error`\n * instance and rejects everything else.\n */\n// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- TError only appears in the return predicate; needed so callers infer the right narrowed type.\nconst defaultIsKnownError = <TError extends Error>(\n error: unknown\n): error is TError => error instanceof Error;\n\n/**\n * Runs retry orchestration in non-throw mode.\n *\n * @param policy - Retry policy: `next` is required, `onExhausted` and\n * `isKnownError` fall back to their defaults when omitted.\n * @param execute - Async function to execute per attempt.\n * @param options - Runner settings with `throwOnExhausted: false`.\n * @returns A discriminated result union containing success value or terminal error.\n * When `policy.isKnownError` rejects a caught value, it is wrapped in a\n * `RetryError` and returned as the terminal failure instead of thrown.\n * @throws {Error} Any error thrown by `next`, `onExhausted`, or a custom `sleep`.\n */\nexport function runRetryPolicy<\n T,\n TError extends Error = Error,\n TData = unknown,\n>(\n policy: RetryPolicy<TError, TData>,\n execute: (attempt: number) => Promise<T>,\n options: RetryRunOptions & { throwOnExhausted: false }\n): Promise<RetryRunResult<T>>;\n\n/**\n * Runs retry orchestration and throws terminal error on exhaustion.\n *\n * @param policy - Retry policy: `next` is required, `onExhausted` and\n * `isKnownError` fall back to their defaults when omitted.\n * @param execute - Async function to execute per attempt.\n * @param options - Optional runner settings.\n * @returns The successful execution value.\n * @throws {RetryError} When retries are exhausted and `onExhausted` returns the\n * terminal retry error. The default implementation returns `RetryError` with the last\n * execution failure available on `RetryError.lastError`.\n * @throws {AbortError} When `options.signal` is already aborted or aborts while retrying.\n * @throws {Error} Any error thrown by `next`, by `onExhausted`, or by a custom `sleep`\n * function.\n *\n * @example\n * ```ts\n * import { runRetryPolicy } from \"@zap-studio/retry\";\n * import type { RetryPolicy } from \"@zap-studio/retry\";\n *\n * const linearBackoff: RetryPolicy = {\n * next: ({ attempt }) =>\n * attempt < 3\n * ? { shouldRetry: true, delayMs: attempt * 100, reason: \"retry\" }\n * : { shouldRetry: false, delayMs: 0, reason: \"max-attempts-reached\" },\n * };\n *\n * const data = await runRetryPolicy(linearBackoff, async () => fetchFlakyResource());\n * ```\n */\nexport function runRetryPolicy<\n T,\n TError extends Error = Error,\n TData = unknown,\n>(\n policy: RetryPolicy<TError, TData>,\n execute: (attempt: number) => Promise<T>,\n options?: RetryRunOptions & { throwOnExhausted?: true }\n): Promise<T>;\n\n/**\n * Runs retry orchestration in non-throw mode.\n *\n * When `throwOnExhausted` is `false`, returns a discriminated result union.\n *\n * @param policy - Retry policy: `next` is required, `onExhausted` and\n * `isKnownError` fall back to their defaults when omitted.\n * @param execute - Async function to execute per attempt.\n * @param options - Runner settings.\n * @returns Success value or terminal result object based on option mode.\n * @throws {Error} Any error thrown by `next`, by `onExhausted`, or by a custom `sleep`\n * function. When `throwOnExhausted` is `false`, exhaustion itself is returned\n * as `{ ok: false }` instead of thrown.\n * Cancellation is returned as `{ ok: false, error: AbortError }` in non-throw\n * mode. A value rejected by `policy.isKnownError` is wrapped in a\n * `RetryError` and returned the same way in non-throw mode; in throw mode\n * it is rethrown as-is.\n *\n * @example\n * const result = await runRetryPolicy(policy, doWork, { throwOnExhausted: false });\n * if (!result.ok) console.error(result.error);\n */\nexport async function runRetryPolicy<\n T,\n TError extends Error = Error,\n TData = unknown,\n>(\n policy: RetryPolicy<TError, TData>,\n execute: (attempt: number) => Promise<T>,\n options: RetryRunOptions = {}\n): Promise<T | RetryRunResult<T>> {\n const sleep = options.sleep ?? defaultSleep;\n const { signal, logger } = options;\n const resolvedPolicy: ResolvedRetryPolicy<TError, TData> = {\n isKnownError: (error): error is TError =>\n policy.isKnownError\n ? policy.isKnownError(error)\n : defaultIsKnownError(error),\n next: (input) => policy.next(input),\n onExhausted: (input) =>\n policy.onExhausted\n ? policy.onExhausted(input)\n : defaultOnExhausted(input),\n };\n\n if (options.throwOnExhausted === false) {\n return await runResultMode(resolvedPolicy, execute, sleep, signal, logger);\n }\n\n return await runThrowMode(resolvedPolicy, execute, sleep, signal, logger);\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"base-policy.d.ts","names":[],"sources":["../src/base-policy.ts"],"mappings":";;;;;;;;;;;;;;;cA+Ba,eAAsB,oBAAkB;;;;;;;;;;;;;iBAicrC,eACd,GACA,eAAe,QAAQ,OACvB,iBAEA,QAAQ,YAAY,QAAQ,QAC5B,UAAU,oBAAoB,QAAQ,IACtC,SAAS;EAAoB;IAC5B,QAAQ,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgCV,eACd,GACA,eAAe,QAAQ,OACvB,iBAEA,QAAQ,YAAY,QAAQ,QAC5B,UAAU,oBAAoB,QAAQ,IACtC,UAAU;EAAoB;IAC7B,QAAQ"}
1
+ {"version":3,"file":"base-policy.d.ts","names":[],"sources":["../src/base-policy.ts"],"mappings":";;;;;;;;;;;;;;;cAiCa,eAAsB,oBAAkB;;;;;;;;;;;;;iBA4crC,eACd,GACA,eAAe,QAAQ,OACvB,iBAEA,QAAQ,YAAY,QAAQ,QAC5B,UAAU,oBAAoB,QAAQ,IACtC,SAAS;EAAoB;IAC5B,QAAQ,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgCV,eACd,GACA,eAAe,QAAQ,OACvB,iBAEA,QAAQ,YAAY,QAAQ,QAC5B,UAAU,oBAAoB,QAAQ,IACtC,UAAU;EAAoB;IAC7B,QAAQ"}
@@ -1,334 +1,3 @@
1
- import { AbortError, RetryError } from "./errors.js";
2
- //#region src/base-policy.ts
3
- /**
4
- * Awaits a timer-based delay, unless `delayMs` is non-positive.
5
- *
6
- * @param delayMs - Milliseconds to wait before resolving.
7
- * @returns Promise that resolves when the delay completes.
8
- *
9
- * @example
10
- * ```ts
11
- * import { defaultSleep } from "@zap-studio/retry";
12
- *
13
- * await defaultSleep(250); // waits 250ms
14
- * ```
15
- */
16
- const defaultSleep = async (delayMs) => {
17
- if (delayMs <= 0) return;
18
- await new Promise((resolve) => {
19
- setTimeout(resolve, delayMs);
20
- });
21
- };
22
- /**
23
- * Normalizes an abort `reason` into an `AbortError`.
24
- */
25
- const toAbortError = (reason) => {
26
- if (reason instanceof AbortError) return reason;
27
- if (reason instanceof Error) return new AbortError(reason.message, { cause: reason });
28
- if (typeof reason === "string" && reason.length > 0) return new AbortError(reason);
29
- if (reason === void 0) return new AbortError("Retry aborted.");
30
- try {
31
- return new AbortError(`Retry aborted: ${JSON.stringify(reason)}`);
32
- } catch {
33
- return new AbortError("Retry aborted.");
34
- }
35
- };
36
- /**
37
- * Throws when the provided abort signal is already aborted.
38
- *
39
- * @param signal - Optional abort signal to inspect.
40
- * @param logger - Optional logger; logs the abort at `debug` before throwing.
41
- * @throws {AbortError} When the signal is aborted.
42
- */
43
- const throwIfAborted = (signal, logger) => {
44
- if (signal?.aborted !== true) return;
45
- logger?.debug("retry aborted", { reason: signal.reason });
46
- throw toAbortError(signal.reason);
47
- };
48
- /**
49
- * Waits for delay sleep while observing cancellation through an abort signal.
50
- *
51
- * @param sleep - Sleep function used to await `delayMs`.
52
- * @param delayMs - Delay duration in milliseconds.
53
- * @param signal - Abort signal to observe while waiting.
54
- * @returns Promise that resolves when delay finishes.
55
- * @throws {AbortError} When the signal aborts before or during wait.
56
- */
57
- const sleepWithAbortSignal = async (sleep, delayMs, signal) => {
58
- if (signal.aborted) throw toAbortError(signal.reason);
59
- let onAbort;
60
- try {
61
- await Promise.race([sleep(delayMs), new Promise((_resolve, reject) => {
62
- onAbort = () => {
63
- reject(toAbortError(signal.reason));
64
- };
65
- signal.addEventListener("abort", onAbort, { once: true });
66
- })]);
67
- } finally {
68
- if (onAbort) signal.removeEventListener("abort", onAbort);
69
- }
70
- };
71
- /**
72
- * Logs a `next(...)` decision: `debug` when retrying, `warn` when exhausted.
73
- * Shared by both the throw-mode and non-throw retry loops.
74
- */
75
- const logRetryDecision = (logger, attempt, decision, error) => {
76
- if (decision.shouldRetry) {
77
- logger?.debug("retry scheduled", {
78
- attempt,
79
- delayMs: decision.delayMs,
80
- reason: decision.reason
81
- });
82
- return;
83
- }
84
- logger?.warn("retry policy exhausted", {
85
- attempts: attempt,
86
- error,
87
- reason: decision.reason
88
- });
89
- };
90
- /**
91
- * Runs the throw-mode retry loop: throws `RetryError` on exhaustion and
92
- * `AbortError` when `signal` aborts.
93
- *
94
- * @param policy - Resolved retry policy providing `next` and `onExhausted`.
95
- * @param execute - Async work callback per attempt.
96
- * @param sleep - Delay function between retries.
97
- * @param signal - Optional cancel signal.
98
- * @param logger - Optional logger; logs each retry decision at `debug` and
99
- * exhaustion at `warn`.
100
- * @returns Resolves to the first successful return value.
101
- * @throws {RetryError} When retries are exhausted and `onExhausted` returns
102
- * the terminal error.
103
- * @throws {AbortError} When `signal` is already aborted or aborts while waiting.
104
- * @throws {Error} Any error thrown by `next`, `onExhausted`, or `sleep`. Also
105
- * rethrows the original caught value immediately, bypassing retry, when
106
- * `policy.isKnownError` rejects it as outside this policy's error domain.
107
- */
108
- const runThrowMode = async (policy, execute, sleep, signal, logger) => {
109
- let attempt = 1;
110
- while (true) {
111
- throwIfAborted(signal, logger);
112
- try {
113
- return await execute(attempt);
114
- } catch (error) {
115
- throwIfAborted(signal, logger);
116
- if (!policy.isKnownError(error)) throw error;
117
- const decision = policy.next({
118
- attempt,
119
- error
120
- });
121
- logRetryDecision(logger, attempt, decision, error);
122
- if (!decision.shouldRetry) throw policy.onExhausted({
123
- attempts: attempt,
124
- error
125
- });
126
- if (decision.delayMs > 0) await (signal === void 0 ? sleep(decision.delayMs) : sleepWithAbortSignal(sleep, decision.delayMs, signal));
127
- attempt += 1;
128
- }
129
- }
130
- };
131
- /**
132
- * When `signal` is already aborted, builds the terminal `{ ok: false }` object
133
- * with a normalized `AbortError` on `error`.
134
- *
135
- * @param signal - Optional abort signal; only acts when `aborted` is set.
136
- * @param attempts - Number of finished attempts to report in the result.
137
- * @param logger - Optional logger; logs the abort at `debug`.
138
- * @returns Failure result or `undefined` if not aborted.
139
- */
140
- const buildAbortResult = (signal, attempts, logger) => {
141
- if (signal?.aborted !== true) return;
142
- logger?.debug("retry aborted", { reason: signal.reason });
143
- return {
144
- attempts,
145
- error: toAbortError(signal.reason),
146
- ok: false
147
- };
148
- };
149
- /**
150
- * Runs one `execute(attempt)` call and returns either a success value or a
151
- * captured error without rethrowing.
152
- *
153
- * @param execute - User work callback.
154
- * @param attempt - One-based attempt number passed to `execute`.
155
- * @returns A tagged success with `value` or a tagged failure with `error`.
156
- */
157
- const runAttempt = async (execute, attempt) => {
158
- try {
159
- return {
160
- ok: true,
161
- value: await execute(attempt)
162
- };
163
- } catch (error) {
164
- return {
165
- error,
166
- ok: false
167
- };
168
- }
169
- };
170
- /**
171
- * Awaits inter-attempt delay in result mode, mapping an abort during wait to
172
- * a terminal result instead of throwing when `throwOnExhausted` is false.
173
- *
174
- * @param sleep - Custom or default sleep implementation.
175
- * @param delayMs - Milliseconds to wait.
176
- * @param signal - If set, `sleep` is raced with the abort signal.
177
- * @param attempts - Attempt count to attach if the wait ends in abort.
178
- * @param logger - Optional logger; logs an abort ending the wait at `debug`.
179
- * @returns A terminal result when canceled during the wait, otherwise
180
- * `undefined`.
181
- * @throws {Error} The underlying `sleep` rejection when it is not an abort.
182
- */
183
- const waitForDelay = async (sleep, delayMs, signal, attempts, logger) => {
184
- if (signal === void 0) {
185
- await sleep(delayMs);
186
- return;
187
- }
188
- try {
189
- await sleepWithAbortSignal(sleep, delayMs, signal);
190
- return;
191
- } catch (error) {
192
- const aborted = buildAbortResult(signal, attempts, logger);
193
- if (aborted !== void 0) return aborted;
194
- throw error;
195
- }
196
- };
197
- /**
198
- * After a failed attempt, applies abort rules, `next`, optional delay, and
199
- * either returns a terminal `RetryRunResult` or `undefined` to continue.
200
- *
201
- * @param policy - Resolved retry policy hooks (`next`, `onExhausted`).
202
- * @param params - Failure context for the current attempt.
203
- * @param params.attempt - Current attempt number.
204
- * @param params.error - Error thrown by the attempt.
205
- * @param params.sleep - Delay function between retries.
206
- * @param params.signal - Optional abort signal.
207
- * @param params.logger - Optional logger; logs each retry decision at
208
- * `debug`, exhaustion at `warn`, and cancellation at `debug`.
209
- * @returns Terminal non-throw result if the loop should stop, otherwise
210
- * `undefined` to schedule another attempt.
211
- * @throws {Error} Any error thrown by `next`, `onExhausted`, or a custom `sleep` when
212
- * the error is not an abort.
213
- */
214
- const handleFailure = async (policy, params) => {
215
- const { attempt, error, sleep, signal, logger } = params;
216
- const abortResult = buildAbortResult(signal, attempt, logger);
217
- if (abortResult !== void 0) return abortResult;
218
- const decision = policy.next({
219
- attempt,
220
- error
221
- });
222
- logRetryDecision(logger, attempt, decision, error);
223
- if (!decision.shouldRetry) return {
224
- attempts: attempt,
225
- error: policy.onExhausted({
226
- attempts: attempt,
227
- error
228
- }),
229
- ok: false
230
- };
231
- if (decision.delayMs > 0) {
232
- const delayAbortResult = await waitForDelay(sleep, decision.delayMs, signal, attempt, logger);
233
- if (delayAbortResult !== void 0) return delayAbortResult;
234
- }
235
- };
236
- /**
237
- * Runs the non-throw retry loop, returning
238
- * `RetryRunResult`.
239
- *
240
- * @param policy - Resolved retry policy providing `next` and `onExhausted`.
241
- * @param execute - Async work callback per attempt.
242
- * @param sleep - Delay function between retries.
243
- * @param signal - Optional cancel signal.
244
- * @param logger - Optional logger; logs each retry decision at `debug`,
245
- * exhaustion at `warn`, and cancellation at `debug`.
246
- * @returns Terminal success or failure object. When `policy.isKnownError`
247
- * rejects a caught value as outside this policy's error domain, the
248
- * original value is wrapped in a `RetryError` and returned on
249
- * `result.error` immediately, bypassing retry — never thrown.
250
- * @throws {Error} Any error thrown by `next`, `onExhausted`, or a non-abort `sleep`
251
- * failure.
252
- */
253
- const runResultMode = async (policy, execute, sleep, signal, logger) => {
254
- let attempt = 1;
255
- while (true) {
256
- const abortResult = buildAbortResult(signal, Math.max(0, attempt - 1), logger);
257
- if (abortResult !== void 0) return abortResult;
258
- const execution = await runAttempt(execute, attempt);
259
- if (execution.ok) return {
260
- ok: true,
261
- value: execution.value
262
- };
263
- const attemptAbortResult = buildAbortResult(signal, attempt, logger);
264
- if (attemptAbortResult !== void 0) return attemptAbortResult;
265
- if (!policy.isKnownError(execution.error)) return {
266
- attempts: attempt,
267
- error: new RetryError("Retry policy encountered an unknown error.", {
268
- attempts: attempt,
269
- lastError: execution.error
270
- }),
271
- ok: false
272
- };
273
- const failure = await handleFailure(policy, {
274
- attempt,
275
- error: execution.error,
276
- logger,
277
- signal,
278
- sleep
279
- });
280
- if (failure !== void 0) return failure;
281
- attempt += 1;
282
- }
283
- };
284
- /**
285
- * Default `onExhausted` used when a policy omits it: wraps the exhaustion
286
- * context in a generic `RetryError`.
287
- */
288
- const defaultOnExhausted = (input) => new RetryError("Retry policy exhausted all attempts.", {
289
- attempts: input.attempts,
290
- lastData: input.data,
291
- lastError: input.error
292
- });
293
- /**
294
- * Default `isKnownError` used when a policy omits it: accepts any `Error`
295
- * instance and rejects everything else.
296
- */
297
- const defaultIsKnownError = (error) => error instanceof Error;
298
- /**
299
- * Runs retry orchestration in non-throw mode.
300
- *
301
- * When `throwOnExhausted` is `false`, returns a discriminated result union.
302
- *
303
- * @param policy - Retry policy: `next` is required, `onExhausted` and
304
- * `isKnownError` fall back to their defaults when omitted.
305
- * @param execute - Async function to execute per attempt.
306
- * @param options - Runner settings.
307
- * @returns Success value or terminal result object based on option mode.
308
- * @throws {Error} Any error thrown by `next`, by `onExhausted`, or by a custom `sleep`
309
- * function. When `throwOnExhausted` is `false`, exhaustion itself is returned
310
- * as `{ ok: false }` instead of thrown.
311
- * Cancellation is returned as `{ ok: false, error: AbortError }` in non-throw
312
- * mode. A value rejected by `policy.isKnownError` is wrapped in a
313
- * `RetryError` and returned the same way in non-throw mode; in throw mode
314
- * it is rethrown as-is.
315
- *
316
- * @example
317
- * const result = await runRetryPolicy(policy, doWork, { throwOnExhausted: false });
318
- * if (!result.ok) console.error(result.error);
319
- */
320
- async function runRetryPolicy(policy, execute, options = {}) {
321
- const sleep = options.sleep ?? defaultSleep;
322
- const { signal, logger } = options;
323
- const resolvedPolicy = {
324
- isKnownError: (error) => policy.isKnownError ? policy.isKnownError(error) : defaultIsKnownError(error),
325
- next: (input) => policy.next(input),
326
- onExhausted: (input) => policy.onExhausted ? policy.onExhausted(input) : defaultOnExhausted(input)
327
- };
328
- if (options.throwOnExhausted === false) return await runResultMode(resolvedPolicy, execute, sleep, signal, logger);
329
- return await runThrowMode(resolvedPolicy, execute, sleep, signal, logger);
330
- }
331
- //#endregion
1
+ import { n as runRetryPolicy, t as defaultSleep } from "./base-policy-CiDqMVSD.js";
2
+ import "./errors.js";
332
3
  export { defaultSleep, runRetryPolicy };
333
-
334
- //# sourceMappingURL=base-policy.js.map
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
+ import { n as runRetryPolicy, t as defaultSleep } from "./base-policy-CiDqMVSD.js";
1
2
  import { AbortError, RetryError } from "./errors.js";
2
- import { defaultSleep, runRetryPolicy } from "./base-policy.js";
3
3
  import { applyJitter } from "./jitter.js";
4
4
  import { exponentialBackoff } from "./exponential-backoff.js";
5
5
  import { fixedDelay } from "./fixed-delay.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zap-studio/retry",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "private": false,
5
5
  "description": "Composable, tree-shakeable retry policies for resilient async operations.",
6
6
  "keywords": [
@@ -43,15 +43,26 @@
43
43
  "publishConfig": {
44
44
  "access": "public"
45
45
  },
46
- "dependencies": {
47
- "@zap-studio/logger": "1.0.0"
48
- },
49
46
  "devDependencies": {
47
+ "@opentelemetry/api": "^1.9.0",
48
+ "@opentelemetry/context-async-hooks": "^2.10.0",
49
+ "@opentelemetry/sdk-metrics": "^2.10.0",
50
+ "@opentelemetry/sdk-trace-base": "^2.10.0",
50
51
  "tsdown": "^0.22.14",
51
52
  "typescript": "^7.0.2",
52
53
  "vitest": "^4.1.10",
54
+ "@zap-studio/logger": "2.0.0",
53
55
  "@zap-studio/typescript": "0.0.0"
54
56
  },
57
+ "peerDependencies": {
58
+ "@opentelemetry/api": "^1.9.0",
59
+ "@zap-studio/logger": "2.0.0"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "@zap-studio/logger": {
63
+ "optional": true
64
+ }
65
+ },
55
66
  "engines": {
56
67
  "node": ">=18.0.0"
57
68
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"base-policy.js","names":[],"sources":["../src/base-policy.ts"],"sourcesContent":["/**\n * Retry runner base class and shared orchestration implementation.\n *\n * @module @zap-studio/retry/base-policy\n */\n\nimport type { Logger } from \"@zap-studio/logger\";\n\nimport { AbortError, RetryError } from \"./errors.js\";\nimport type {\n ResolvedRetryPolicy,\n RetryDecision,\n RetryExhaustedInput,\n RetryPolicy,\n RetryRunOptions,\n RetryRunResult,\n} from \"./types.js\";\n\n/**\n * Awaits a timer-based delay, unless `delayMs` is non-positive.\n *\n * @param delayMs - Milliseconds to wait before resolving.\n * @returns Promise that resolves when the delay completes.\n *\n * @example\n * ```ts\n * import { defaultSleep } from \"@zap-studio/retry\";\n *\n * await defaultSleep(250); // waits 250ms\n * ```\n */\nexport const defaultSleep = async (delayMs: number): Promise<void> => {\n if (delayMs <= 0) {\n return;\n }\n\n // oxlint-disable-next-line promise/avoid-new -- Timer sleep requires adapting callback API to a promise.\n await new Promise<void>((resolve) => {\n setTimeout(resolve, delayMs);\n });\n};\n\n/**\n * Normalizes an abort `reason` into an `AbortError`.\n */\nconst toAbortError = (reason: unknown): AbortError => {\n if (reason instanceof AbortError) {\n return reason;\n }\n\n if (reason instanceof Error) {\n return new AbortError(reason.message, { cause: reason });\n }\n\n if (typeof reason === \"string\" && reason.length > 0) {\n return new AbortError(reason);\n }\n\n if (reason === undefined) {\n return new AbortError(\"Retry aborted.\");\n }\n\n try {\n return new AbortError(`Retry aborted: ${JSON.stringify(reason)}`);\n } catch {\n return new AbortError(\"Retry aborted.\");\n }\n};\n\n/**\n * Throws when the provided abort signal is already aborted.\n *\n * @param signal - Optional abort signal to inspect.\n * @param logger - Optional logger; logs the abort at `debug` before throwing.\n * @throws {AbortError} When the signal is aborted.\n */\nconst throwIfAborted = (signal?: AbortSignal, logger?: Logger): void => {\n if (signal?.aborted !== true) {\n return;\n }\n\n logger?.debug(\"retry aborted\", { reason: signal.reason });\n throw toAbortError(signal.reason);\n};\n\n/**\n * Waits for delay sleep while observing cancellation through an abort signal.\n *\n * @param sleep - Sleep function used to await `delayMs`.\n * @param delayMs - Delay duration in milliseconds.\n * @param signal - Abort signal to observe while waiting.\n * @returns Promise that resolves when delay finishes.\n * @throws {AbortError} When the signal aborts before or during wait.\n */\nconst sleepWithAbortSignal = async (\n sleep: (delayMs: number) => Promise<void>,\n delayMs: number,\n signal: AbortSignal\n): Promise<void> => {\n if (signal.aborted) {\n throw toAbortError(signal.reason);\n }\n\n let onAbort: (() => void) | undefined;\n\n try {\n await Promise.race([\n sleep(delayMs),\n // oxlint-disable-next-line promise/avoid-new -- AbortSignal callback is adapted into the race promise.\n new Promise<never>((_resolve, reject) => {\n onAbort = (): void => {\n reject(toAbortError(signal.reason));\n };\n\n signal.addEventListener(\"abort\", onAbort, { once: true });\n }),\n ]);\n } finally {\n if (onAbort) {\n signal.removeEventListener(\"abort\", onAbort);\n }\n }\n};\n\n/**\n * Logs a `next(...)` decision: `debug` when retrying, `warn` when exhausted.\n * Shared by both the throw-mode and non-throw retry loops.\n */\nconst logRetryDecision = (\n logger: Logger | undefined,\n attempt: number,\n decision: RetryDecision,\n error: unknown\n): void => {\n if (decision.shouldRetry) {\n logger?.debug(\"retry scheduled\", {\n attempt,\n delayMs: decision.delayMs,\n reason: decision.reason,\n });\n return;\n }\n\n logger?.warn(\"retry policy exhausted\", {\n attempts: attempt,\n error,\n reason: decision.reason,\n });\n};\n\n/**\n * Runs the throw-mode retry loop: throws `RetryError` on exhaustion and\n * `AbortError` when `signal` aborts.\n *\n * @param policy - Resolved retry policy providing `next` and `onExhausted`.\n * @param execute - Async work callback per attempt.\n * @param sleep - Delay function between retries.\n * @param signal - Optional cancel signal.\n * @param logger - Optional logger; logs each retry decision at `debug` and\n * exhaustion at `warn`.\n * @returns Resolves to the first successful return value.\n * @throws {RetryError} When retries are exhausted and `onExhausted` returns\n * the terminal error.\n * @throws {AbortError} When `signal` is already aborted or aborts while waiting.\n * @throws {Error} Any error thrown by `next`, `onExhausted`, or `sleep`. Also\n * rethrows the original caught value immediately, bypassing retry, when\n * `policy.isKnownError` rejects it as outside this policy's error domain.\n */\nconst runThrowMode = async <T, TError extends Error, TData>(\n policy: ResolvedRetryPolicy<TError, TData>,\n execute: (attempt: number) => Promise<T>,\n sleep: (delayMs: number) => Promise<void>,\n signal?: AbortSignal,\n logger?: Logger\n): Promise<T> => {\n let attempt = 1;\n\n while (true) {\n throwIfAborted(signal, logger);\n\n try {\n // oxlint-disable-next-line no-await-in-loop -- Retry attempts must run sequentially.\n return await execute(attempt);\n } catch (error) {\n throwIfAborted(signal, logger);\n\n if (!policy.isKnownError(error)) {\n throw error;\n }\n\n const decision = policy.next({\n attempt,\n error,\n });\n logRetryDecision(logger, attempt, decision, error);\n\n if (!decision.shouldRetry) {\n throw policy.onExhausted({\n attempts: attempt,\n error,\n });\n }\n\n if (decision.delayMs > 0) {\n // oxlint-disable-next-line no-await-in-loop -- Delay belongs between sequential retry attempts.\n await (signal === undefined\n ? sleep(decision.delayMs)\n : sleepWithAbortSignal(sleep, decision.delayMs, signal));\n }\n\n attempt += 1;\n }\n }\n};\n\n/**\n * When `signal` is already aborted, builds the terminal `{ ok: false }` object\n * with a normalized `AbortError` on `error`.\n *\n * @param signal - Optional abort signal; only acts when `aborted` is set.\n * @param attempts - Number of finished attempts to report in the result.\n * @param logger - Optional logger; logs the abort at `debug`.\n * @returns Failure result or `undefined` if not aborted.\n */\nconst buildAbortResult = (\n signal: AbortSignal | undefined,\n attempts: number,\n logger?: Logger\n): RetryRunResult<never> | undefined => {\n if (signal?.aborted !== true) {\n return undefined;\n }\n\n logger?.debug(\"retry aborted\", { reason: signal.reason });\n\n return {\n attempts,\n error: toAbortError(signal.reason),\n ok: false,\n };\n};\n\n/**\n * Runs one `execute(attempt)` call and returns either a success value or a\n * captured error without rethrowing.\n *\n * @param execute - User work callback.\n * @param attempt - One-based attempt number passed to `execute`.\n * @returns A tagged success with `value` or a tagged failure with `error`.\n */\nconst runAttempt = async <T>(\n execute: (attempt: number) => Promise<T>,\n attempt: number\n): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> => {\n try {\n return {\n ok: true,\n value: await execute(attempt),\n };\n } catch (error) {\n return {\n error,\n ok: false,\n };\n }\n};\n\n/**\n * Awaits inter-attempt delay in result mode, mapping an abort during wait to\n * a terminal result instead of throwing when `throwOnExhausted` is false.\n *\n * @param sleep - Custom or default sleep implementation.\n * @param delayMs - Milliseconds to wait.\n * @param signal - If set, `sleep` is raced with the abort signal.\n * @param attempts - Attempt count to attach if the wait ends in abort.\n * @param logger - Optional logger; logs an abort ending the wait at `debug`.\n * @returns A terminal result when canceled during the wait, otherwise\n * `undefined`.\n * @throws {Error} The underlying `sleep` rejection when it is not an abort.\n */\nconst waitForDelay = async (\n sleep: (delayMs: number) => Promise<void>,\n delayMs: number,\n signal: AbortSignal | undefined,\n attempts: number,\n logger?: Logger\n): Promise<RetryRunResult<never> | undefined> => {\n if (signal === undefined) {\n await sleep(delayMs);\n return undefined;\n }\n\n try {\n await sleepWithAbortSignal(sleep, delayMs, signal);\n return undefined;\n } catch (error) {\n const aborted = buildAbortResult(signal, attempts, logger);\n if (aborted !== undefined) {\n return aborted;\n }\n throw error;\n }\n};\n\n/**\n * After a failed attempt, applies abort rules, `next`, optional delay, and\n * either returns a terminal `RetryRunResult` or `undefined` to continue.\n *\n * @param policy - Resolved retry policy hooks (`next`, `onExhausted`).\n * @param params - Failure context for the current attempt.\n * @param params.attempt - Current attempt number.\n * @param params.error - Error thrown by the attempt.\n * @param params.sleep - Delay function between retries.\n * @param params.signal - Optional abort signal.\n * @param params.logger - Optional logger; logs each retry decision at\n * `debug`, exhaustion at `warn`, and cancellation at `debug`.\n * @returns Terminal non-throw result if the loop should stop, otherwise\n * `undefined` to schedule another attempt.\n * @throws {Error} Any error thrown by `next`, `onExhausted`, or a custom `sleep` when\n * the error is not an abort.\n */\nconst handleFailure = async <TError extends Error, TData>(\n policy: ResolvedRetryPolicy<TError, TData>,\n params: {\n attempt: number;\n error: TError;\n sleep: (delayMs: number) => Promise<void>;\n signal: AbortSignal | undefined;\n logger: Logger | undefined;\n }\n): Promise<RetryRunResult<never> | undefined> => {\n const { attempt, error, sleep, signal, logger } = params;\n const abortResult = buildAbortResult(signal, attempt, logger);\n if (abortResult !== undefined) {\n return abortResult;\n }\n\n const decision = policy.next({\n attempt,\n error,\n });\n logRetryDecision(logger, attempt, decision, error);\n\n if (!decision.shouldRetry) {\n const terminalError = policy.onExhausted({\n attempts: attempt,\n error,\n });\n\n return {\n attempts: attempt,\n error: terminalError,\n ok: false,\n };\n }\n\n if (decision.delayMs > 0) {\n const delayAbortResult = await waitForDelay(\n sleep,\n decision.delayMs,\n signal,\n attempt,\n logger\n );\n if (delayAbortResult !== undefined) {\n return delayAbortResult;\n }\n }\n\n return undefined;\n};\n\n/**\n * Runs the non-throw retry loop, returning\n * `RetryRunResult`.\n *\n * @param policy - Resolved retry policy providing `next` and `onExhausted`.\n * @param execute - Async work callback per attempt.\n * @param sleep - Delay function between retries.\n * @param signal - Optional cancel signal.\n * @param logger - Optional logger; logs each retry decision at `debug`,\n * exhaustion at `warn`, and cancellation at `debug`.\n * @returns Terminal success or failure object. When `policy.isKnownError`\n * rejects a caught value as outside this policy's error domain, the\n * original value is wrapped in a `RetryError` and returned on\n * `result.error` immediately, bypassing retry — never thrown.\n * @throws {Error} Any error thrown by `next`, `onExhausted`, or a non-abort `sleep`\n * failure.\n */\nconst runResultMode = async <T, TError extends Error, TData>(\n policy: ResolvedRetryPolicy<TError, TData>,\n execute: (attempt: number) => Promise<T>,\n sleep: (delayMs: number) => Promise<void>,\n signal?: AbortSignal,\n logger?: Logger\n): Promise<RetryRunResult<T>> => {\n let attempt = 1;\n\n while (true) {\n const abortResult = buildAbortResult(\n signal,\n Math.max(0, attempt - 1),\n logger\n );\n if (abortResult !== undefined) {\n return abortResult;\n }\n\n // oxlint-disable-next-line no-await-in-loop -- Retry attempts must run sequentially.\n const execution = await runAttempt(execute, attempt);\n if (execution.ok) {\n return { ok: true, value: execution.value };\n }\n\n const attemptAbortResult = buildAbortResult(signal, attempt, logger);\n if (attemptAbortResult !== undefined) {\n return attemptAbortResult;\n }\n\n if (!policy.isKnownError(execution.error)) {\n return {\n attempts: attempt,\n error: new RetryError(\"Retry policy encountered an unknown error.\", {\n attempts: attempt,\n lastError: execution.error,\n }),\n ok: false,\n };\n }\n\n // oxlint-disable-next-line no-await-in-loop -- Failure handling belongs to the current sequential attempt.\n const failure = await handleFailure(policy, {\n attempt,\n error: execution.error,\n logger,\n signal,\n sleep,\n });\n if (failure !== undefined) {\n return failure;\n }\n\n attempt += 1;\n }\n};\n\n/**\n * Default `onExhausted` used when a policy omits it: wraps the exhaustion\n * context in a generic `RetryError`.\n */\nconst defaultOnExhausted = <TError extends Error, TData>(\n input: RetryExhaustedInput<TError, TData>\n): RetryError =>\n new RetryError(\"Retry policy exhausted all attempts.\", {\n attempts: input.attempts,\n lastData: input.data,\n lastError: input.error,\n });\n\n/**\n * Default `isKnownError` used when a policy omits it: accepts any `Error`\n * instance and rejects everything else.\n */\n// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- TError only appears in the return predicate; needed so callers infer the right narrowed type.\nconst defaultIsKnownError = <TError extends Error>(\n error: unknown\n): error is TError => error instanceof Error;\n\n/**\n * Runs retry orchestration in non-throw mode.\n *\n * @param policy - Retry policy: `next` is required, `onExhausted` and\n * `isKnownError` fall back to their defaults when omitted.\n * @param execute - Async function to execute per attempt.\n * @param options - Runner settings with `throwOnExhausted: false`.\n * @returns A discriminated result union containing success value or terminal error.\n * When `policy.isKnownError` rejects a caught value, it is wrapped in a\n * `RetryError` and returned as the terminal failure instead of thrown.\n * @throws {Error} Any error thrown by `next`, `onExhausted`, or a custom `sleep`.\n */\nexport function runRetryPolicy<\n T,\n TError extends Error = Error,\n TData = unknown,\n>(\n policy: RetryPolicy<TError, TData>,\n execute: (attempt: number) => Promise<T>,\n options: RetryRunOptions & { throwOnExhausted: false }\n): Promise<RetryRunResult<T>>;\n\n/**\n * Runs retry orchestration and throws terminal error on exhaustion.\n *\n * @param policy - Retry policy: `next` is required, `onExhausted` and\n * `isKnownError` fall back to their defaults when omitted.\n * @param execute - Async function to execute per attempt.\n * @param options - Optional runner settings.\n * @returns The successful execution value.\n * @throws {RetryError} When retries are exhausted and `onExhausted` returns the\n * terminal retry error. The default implementation returns `RetryError` with the last\n * execution failure available on `RetryError.lastError`.\n * @throws {AbortError} When `options.signal` is already aborted or aborts while retrying.\n * @throws {Error} Any error thrown by `next`, by `onExhausted`, or by a custom `sleep`\n * function.\n *\n * @example\n * ```ts\n * import { runRetryPolicy } from \"@zap-studio/retry\";\n * import type { RetryPolicy } from \"@zap-studio/retry\";\n *\n * const linearBackoff: RetryPolicy = {\n * next: ({ attempt }) =>\n * attempt < 3\n * ? { shouldRetry: true, delayMs: attempt * 100, reason: \"retry\" }\n * : { shouldRetry: false, delayMs: 0, reason: \"max-attempts-reached\" },\n * };\n *\n * const data = await runRetryPolicy(linearBackoff, async () => fetchFlakyResource());\n * ```\n */\nexport function runRetryPolicy<\n T,\n TError extends Error = Error,\n TData = unknown,\n>(\n policy: RetryPolicy<TError, TData>,\n execute: (attempt: number) => Promise<T>,\n options?: RetryRunOptions & { throwOnExhausted?: true }\n): Promise<T>;\n\n/**\n * Runs retry orchestration in non-throw mode.\n *\n * When `throwOnExhausted` is `false`, returns a discriminated result union.\n *\n * @param policy - Retry policy: `next` is required, `onExhausted` and\n * `isKnownError` fall back to their defaults when omitted.\n * @param execute - Async function to execute per attempt.\n * @param options - Runner settings.\n * @returns Success value or terminal result object based on option mode.\n * @throws {Error} Any error thrown by `next`, by `onExhausted`, or by a custom `sleep`\n * function. When `throwOnExhausted` is `false`, exhaustion itself is returned\n * as `{ ok: false }` instead of thrown.\n * Cancellation is returned as `{ ok: false, error: AbortError }` in non-throw\n * mode. A value rejected by `policy.isKnownError` is wrapped in a\n * `RetryError` and returned the same way in non-throw mode; in throw mode\n * it is rethrown as-is.\n *\n * @example\n * const result = await runRetryPolicy(policy, doWork, { throwOnExhausted: false });\n * if (!result.ok) console.error(result.error);\n */\nexport async function runRetryPolicy<\n T,\n TError extends Error = Error,\n TData = unknown,\n>(\n policy: RetryPolicy<TError, TData>,\n execute: (attempt: number) => Promise<T>,\n options: RetryRunOptions = {}\n): Promise<T | RetryRunResult<T>> {\n const sleep = options.sleep ?? defaultSleep;\n const { signal, logger } = options;\n const resolvedPolicy: ResolvedRetryPolicy<TError, TData> = {\n isKnownError: (error): error is TError =>\n policy.isKnownError\n ? policy.isKnownError(error)\n : defaultIsKnownError(error),\n next: (input) => policy.next(input),\n onExhausted: (input) =>\n policy.onExhausted\n ? policy.onExhausted(input)\n : defaultOnExhausted(input),\n };\n\n if (options.throwOnExhausted === false) {\n return await runResultMode(resolvedPolicy, execute, sleep, signal, logger);\n }\n\n return await runThrowMode(resolvedPolicy, execute, sleep, signal, logger);\n}\n"],"mappings":"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"}