@zap-studio/retry 1.2.1 → 2.1.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.1.0]
8
+
9
+ ### Added
10
+
11
+ - Added `runRetryPolicyResult(policy, execute, options?)`, a `ResultAsync`-returning alternative to `runRetryPolicy`, backed by the new `@zap-studio/monads` dependency. Additive and opt-in — `runRetryPolicy` (both throw and non-throw modes) is unchanged. No `throwOnExhausted` option; it always returns a `Result`. `Err`'s payload is the same `RetryError`/`AbortError` object `runRetryPolicy`'s throw mode would throw, preserving `RetryError.attempts`/`lastError`/`lastData` and `AbortError.cause`.
12
+
13
+ ## [2.0.0]
14
+
15
+ ### Added
16
+
17
+ 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).
18
+
19
+ ### Changed
20
+
21
+ **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`.
22
+
7
23
  ## [1.2.1]
8
24
 
9
25
  ### Changed
package/README.md CHANGED
@@ -27,6 +27,7 @@ npm install @zap-studio/retry
27
27
  - **A shared runner** via `runRetryPolicy(policy, execute, options?)` with attempt-aware callbacks and custom sleep injection.
28
28
  - **Structured terminal errors**: `RetryError` on exhaustion, `AbortError` on cancellation.
29
29
  - **Non-throw mode** (`throwOnExhausted: false`) returns a `RetryRunResult` instead of throwing.
30
+ - **`Result`-returning variant** via `runRetryPolicyResult(policy, execute, options?)`, for explicit error handling with [`@zap-studio/monads`](https://www.npmjs.com/package/@zap-studio/monads) instead of throw/catch.
30
31
  - **Cancellation** through `AbortSignal`, checked before, between, and during retries.
31
32
  - **Custom policies** as plain objects implementing `RetryPolicy` — just a `next(...)` function, no subclassing.
32
33
  - **Optional logging** via a `logger?: Logger` option ([`@zap-studio/logger`](https://www.npmjs.com/package/@zap-studio/logger)) — omit it and there's zero logging overhead.
@@ -55,7 +56,7 @@ const data = await runRetryPolicy(
55
56
  });
56
57
  return await response.json();
57
58
  },
58
- { logger }
59
+ { logger },
59
60
  );
60
61
  ```
61
62
 
@@ -64,11 +65,7 @@ const data = await runRetryPolicy(
64
65
  `fixedDelay(...)`, `linearBackoff(...)`, and `exponentialBackoff(...)`.
65
66
 
66
67
  ```ts
67
- import {
68
- exponentialBackoff,
69
- fixedDelay,
70
- linearBackoff,
71
- } from "@zap-studio/retry";
68
+ import { exponentialBackoff, fixedDelay, linearBackoff } from "@zap-studio/retry";
72
69
 
73
70
  const exponential = exponentialBackoff({
74
71
  maxAttempts: 5,
@@ -117,8 +114,7 @@ import { AbortError, RetryError, runRetryPolicy } from "@zap-studio/retry";
117
114
  try {
118
115
  await runRetryPolicy(policy, execute);
119
116
  } catch (error) {
120
- if (error instanceof RetryError)
121
- console.error(error.attempts, error.lastError);
117
+ if (error instanceof RetryError) console.error(error.attempts, error.lastError);
122
118
  if (error instanceof AbortError) console.error(error.message);
123
119
  }
124
120
  ```
@@ -139,6 +135,25 @@ if (!result.ok) {
139
135
  }
140
136
  ```
141
137
 
138
+ ## Result-Returning Variant
139
+
140
+ `runRetryPolicyResult(policy, execute, options?)` — additive alternative to `runRetryPolicy` for consumers who prefer explicit [`Result`](https://www.zapstudio.dev/monads/result)/[`ResultAsync`](https://www.zapstudio.dev/monads/result-async) values over throw/catch. There's no `throwOnExhausted` option — it always returns a `Result`.
141
+
142
+ ```ts
143
+ import { isOk } from "@zap-studio/monads";
144
+ import { runRetryPolicyResult } from "@zap-studio/retry";
145
+
146
+ const result = await runRetryPolicyResult(policy, execute);
147
+
148
+ if (isOk(result)) {
149
+ console.log(result.value);
150
+ } else {
151
+ console.error(result.error); // RetryError | AbortError, same as runRetryPolicy's throw mode
152
+ }
153
+ ```
154
+
155
+ For exhaustion and cancellation, `Err` contains the same `RetryError`/`AbortError` object `runRetryPolicy`'s throw mode would throw — `RetryError.attempts`/`lastError`/`lastData` and `AbortError.cause` are preserved. A value rejected by `policy.isKnownError` is instead wrapped in a new `RetryError`, because throw mode rethrows that value as-is.
156
+
142
157
  ## Cancellation
143
158
 
144
159
  Through `AbortSignal`, checked before, between, and during retries.
@@ -157,11 +172,7 @@ As plain objects implementing `RetryPolicy` — just a `next(...)` function, no
157
172
 
158
173
  ```ts
159
174
  import { runRetryPolicy } from "@zap-studio/retry";
160
- import type {
161
- RetryDecision,
162
- RetryDecisionInput,
163
- RetryPolicy,
164
- } from "@zap-studio/retry";
175
+ import type { RetryDecision, RetryDecisionInput, RetryPolicy } from "@zap-studio/retry";
165
176
 
166
177
  const stepDelay = (maxAttempts: number, stepMs: number): RetryPolicy => ({
167
178
  next(input: RetryDecisionInput): RetryDecision {
@@ -195,6 +206,28 @@ await runRetryPolicy(policy, execute, { logger });
195
206
 
196
207
  Each retry decision logs at `debug` (attempt, delay, reason), exhaustion logs at `warn`, and cancellation logs at `debug`.
197
208
 
209
+ ## OpenTelemetry
210
+
211
+ `@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.
212
+
213
+ 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:
214
+
215
+ ```bash
216
+ npm install @opentelemetry/api
217
+ ```
218
+
219
+ ```ts
220
+ import { exponentialBackoff, runRetryPolicy } from "@zap-studio/retry";
221
+
222
+ const policy = exponentialBackoff({ maxAttempts: 5, baseDelayMs: 100 });
223
+
224
+ // If a span is active when this runs (e.g. inside a caller's own span, or
225
+ // nested inside a @zap-studio/fetch call), each retry adds a
226
+ // "retry.scheduled" or "retry.exhausted" event to it. If not, it's a no-op
227
+ // — no wiring required either way.
228
+ await runRetryPolicy(policy, execute);
229
+ ```
230
+
198
231
  ## Runtime Support
199
232
 
200
233
  | Runtime | Minimum version |
@@ -0,0 +1,454 @@
1
+ import { AbortError, RetryError } from "./errors.js";
2
+ import { metrics, trace } from "@opentelemetry/api";
3
+ import { ResultAsync, err, ok } from "@zap-studio/monads";
4
+ //#region package.json
5
+ var name = "@zap-studio/retry";
6
+ var version = "2.1.0";
7
+ //#endregion
8
+ //#region src/_otel.ts
9
+ /**
10
+ * Internal OpenTelemetry wiring for the retry package: the attempts counter.
11
+ * Kept out of `base-policy.ts` so retry orchestration doesn't get tangled
12
+ * with metrics concerns. Span events are added directly to whatever span is
13
+ * already active (e.g. a caller's fetch span), so this package never
14
+ * creates its own tracer.
15
+ *
16
+ * @module @zap-studio/retry/otel
17
+ */
18
+ /**
19
+ * Records one retry decision, tagged with `retry.decision: "retry" |
20
+ * "exhausted"`.
21
+ *
22
+ * Resolves the meter and counter fresh on every call instead of caching
23
+ * them at module scope: unlike `trace.getTracer()`, `metrics.getMeter()`
24
+ * has no proxy indirection — a reference grabbed before an app registers
25
+ * its `MeterProvider` (the common case, since ESM imports resolve before
26
+ * the importing module's own SDK-bootstrap code runs) would stay a no-op
27
+ * forever. Repeated `createCounter` calls with the same name are cheap and
28
+ * idempotent, so this costs nothing meaningful.
29
+ */
30
+ const recordRetryAttempt = (decision) => {
31
+ metrics.getMeter(name, version).createCounter("retry.attempts", { description: "Number of retry decisions made, tagged by outcome." }).add(1, { "retry.decision": decision });
32
+ };
33
+ //#endregion
34
+ //#region src/base-policy.ts
35
+ /**
36
+ * Awaits a timer-based delay, unless `delayMs` is non-positive.
37
+ *
38
+ * @param delayMs - Milliseconds to wait before resolving.
39
+ * @returns Promise that resolves when the delay completes.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * import { defaultSleep } from "@zap-studio/retry";
44
+ *
45
+ * await defaultSleep(250); // waits 250ms
46
+ * ```
47
+ */
48
+ const defaultSleep = async (delayMs) => {
49
+ if (delayMs <= 0) return;
50
+ await new Promise((resolve) => {
51
+ setTimeout(resolve, delayMs);
52
+ });
53
+ };
54
+ /**
55
+ * Normalizes an abort `reason` into an `AbortError`.
56
+ */
57
+ const toAbortError = (reason) => {
58
+ if (reason instanceof AbortError) return reason;
59
+ if (reason instanceof Error) return new AbortError(reason.message, { cause: reason });
60
+ if (typeof reason === "string" && reason.length > 0) return new AbortError(reason);
61
+ if (reason === void 0) return new AbortError("Retry aborted.");
62
+ try {
63
+ return new AbortError(`Retry aborted: ${JSON.stringify(reason)}`);
64
+ } catch {
65
+ return new AbortError("Retry aborted.");
66
+ }
67
+ };
68
+ /**
69
+ * Throws when the provided abort signal is already aborted.
70
+ *
71
+ * @param signal - Optional abort signal to inspect.
72
+ * @param logger - Optional logger; logs the abort at `debug` before throwing.
73
+ * @throws {AbortError} When the signal is aborted.
74
+ */
75
+ const throwIfAborted = (signal, logger) => {
76
+ if (signal?.aborted !== true) return;
77
+ logger?.debug("retry aborted", { reason: signal.reason });
78
+ throw toAbortError(signal.reason);
79
+ };
80
+ /**
81
+ * Waits for delay sleep while observing cancellation through an abort signal.
82
+ *
83
+ * @param sleep - Sleep function used to await `delayMs`.
84
+ * @param delayMs - Delay duration in milliseconds.
85
+ * @param signal - Abort signal to observe while waiting.
86
+ * @returns Promise that resolves when delay finishes.
87
+ * @throws {AbortError} When the signal aborts before or during wait.
88
+ */
89
+ const sleepWithAbortSignal = async (sleep, delayMs, signal) => {
90
+ if (signal.aborted) throw toAbortError(signal.reason);
91
+ let onAbort;
92
+ try {
93
+ await Promise.race([sleep(delayMs), new Promise((_resolve, reject) => {
94
+ onAbort = () => {
95
+ reject(toAbortError(signal.reason));
96
+ };
97
+ signal.addEventListener("abort", onAbort, { once: true });
98
+ })]);
99
+ } finally {
100
+ if (onAbort) signal.removeEventListener("abort", onAbort);
101
+ }
102
+ };
103
+ /**
104
+ * Logs a `next(...)` decision: `debug` when retrying, `warn` when exhausted.
105
+ * Shared by both the throw-mode and non-throw retry loops.
106
+ */
107
+ const logRetryDecision = (logger, attempt, decision, error) => {
108
+ if (decision.shouldRetry) {
109
+ logger?.debug("retry scheduled", {
110
+ attempt,
111
+ delayMs: decision.delayMs,
112
+ reason: decision.reason
113
+ });
114
+ trace.getActiveSpan()?.addEvent("retry.scheduled", {
115
+ attempt,
116
+ "retry.delay_ms": decision.delayMs,
117
+ "retry.reason": decision.reason ?? ""
118
+ });
119
+ recordRetryAttempt("retry");
120
+ return;
121
+ }
122
+ logger?.warn("retry policy exhausted", {
123
+ attempts: attempt,
124
+ error,
125
+ reason: decision.reason
126
+ });
127
+ trace.getActiveSpan()?.addEvent("retry.exhausted", {
128
+ attempt,
129
+ "retry.reason": decision.reason ?? ""
130
+ });
131
+ recordRetryAttempt("exhausted");
132
+ };
133
+ /**
134
+ * Runs the throw-mode retry loop: throws `RetryError` on exhaustion and
135
+ * `AbortError` when `signal` aborts.
136
+ *
137
+ * @param policy - Resolved retry policy providing `next` and `onExhausted`.
138
+ * @param execute - Async work callback per attempt.
139
+ * @param sleep - Delay function between retries.
140
+ * @param signal - Optional cancel signal.
141
+ * @param logger - Optional logger; logs each retry decision at `debug` and
142
+ * exhaustion at `warn`.
143
+ * @returns Resolves to the first successful return value.
144
+ * @throws {RetryError} When retries are exhausted and `onExhausted` returns
145
+ * the terminal error.
146
+ * @throws {AbortError} When `signal` is already aborted or aborts while waiting.
147
+ * @throws {Error} Any error thrown by `next`, `onExhausted`, or `sleep`. Also
148
+ * rethrows the original caught value immediately, bypassing retry, when
149
+ * `policy.isKnownError` rejects it as outside this policy's error domain.
150
+ */
151
+ const runThrowMode = async (policy, execute, sleep, signal, logger) => {
152
+ let attempt = 1;
153
+ while (true) {
154
+ throwIfAborted(signal, logger);
155
+ try {
156
+ return await execute(attempt);
157
+ } catch (error) {
158
+ throwIfAborted(signal, logger);
159
+ if (!policy.isKnownError(error)) throw error;
160
+ await handleThrowModeRetry(policy, {
161
+ attempt,
162
+ error,
163
+ logger,
164
+ signal,
165
+ sleep
166
+ });
167
+ attempt += 1;
168
+ }
169
+ }
170
+ };
171
+ /**
172
+ * After a failed, known-domain attempt in throw mode, applies the policy's
173
+ * retry decision: throws the terminal error from `onExhausted` on
174
+ * exhaustion, otherwise waits out the retry delay so the caller's loop can
175
+ * continue.
176
+ *
177
+ * @param policy - Resolved retry policy providing `next` and `onExhausted`.
178
+ * @param params - Failure context for the current attempt.
179
+ * @param params.attempt - Current attempt number.
180
+ * @param params.error - Error thrown by the attempt, already known to the policy's domain.
181
+ * @param params.sleep - Delay function between retries.
182
+ * @param params.signal - Optional abort signal.
183
+ * @param params.logger - Optional logger; logs each retry decision at `debug`
184
+ * and exhaustion at `warn`.
185
+ * @throws {RetryError} When retries are exhausted and `onExhausted` returns
186
+ * the terminal error.
187
+ * @throws {AbortError} When `signal` aborts while waiting for the retry delay.
188
+ * @throws {Error} Any error thrown by `next`, `onExhausted`, or `sleep`.
189
+ */
190
+ const handleThrowModeRetry = async (policy, params) => {
191
+ const { attempt, error, sleep, signal, logger } = params;
192
+ const decision = policy.next({
193
+ attempt,
194
+ error
195
+ });
196
+ logRetryDecision(logger, attempt, decision, error);
197
+ if (!decision.shouldRetry) throw policy.onExhausted({
198
+ attempts: attempt,
199
+ error
200
+ });
201
+ if (decision.delayMs > 0) await (signal === void 0 ? sleep(decision.delayMs) : sleepWithAbortSignal(sleep, decision.delayMs, signal));
202
+ };
203
+ /**
204
+ * When `signal` is already aborted, builds the terminal `{ ok: false }` object
205
+ * with a normalized `AbortError` on `error`.
206
+ *
207
+ * @param signal - Optional abort signal; only acts when `aborted` is set.
208
+ * @param attempts - Number of finished attempts to report in the result.
209
+ * @param logger - Optional logger; logs the abort at `debug`.
210
+ * @returns Failure result or `undefined` if not aborted.
211
+ */
212
+ const buildAbortResult = (signal, attempts, logger) => {
213
+ if (signal?.aborted !== true) return;
214
+ logger?.debug("retry aborted", { reason: signal.reason });
215
+ return {
216
+ attempts,
217
+ error: toAbortError(signal.reason),
218
+ ok: false
219
+ };
220
+ };
221
+ /**
222
+ * Runs one `execute(attempt)` call and returns either a success value or a
223
+ * captured error without rethrowing.
224
+ *
225
+ * @param execute - User work callback.
226
+ * @param attempt - One-based attempt number passed to `execute`.
227
+ * @returns A tagged success with `value` or a tagged failure with `error`.
228
+ */
229
+ const runAttempt = async (execute, attempt) => {
230
+ try {
231
+ return {
232
+ ok: true,
233
+ value: await execute(attempt)
234
+ };
235
+ } catch (error) {
236
+ return {
237
+ error,
238
+ ok: false
239
+ };
240
+ }
241
+ };
242
+ /**
243
+ * Awaits inter-attempt delay in result mode, mapping an abort during wait to
244
+ * a terminal result instead of throwing when `throwOnExhausted` is false.
245
+ *
246
+ * @param sleep - Custom or default sleep implementation.
247
+ * @param delayMs - Milliseconds to wait.
248
+ * @param signal - If set, `sleep` is raced with the abort signal.
249
+ * @param attempts - Attempt count to attach if the wait ends in abort.
250
+ * @param logger - Optional logger; logs an abort ending the wait at `debug`.
251
+ * @returns A terminal result when canceled during the wait, otherwise
252
+ * `undefined`.
253
+ * @throws {Error} The underlying `sleep` rejection when it is not an abort.
254
+ */
255
+ const waitForDelay = async (sleep, delayMs, signal, attempts, logger) => {
256
+ if (signal === void 0) {
257
+ await sleep(delayMs);
258
+ return;
259
+ }
260
+ try {
261
+ await sleepWithAbortSignal(sleep, delayMs, signal);
262
+ return;
263
+ } catch (error) {
264
+ const aborted = buildAbortResult(signal, attempts, logger);
265
+ if (aborted !== void 0) return aborted;
266
+ throw error;
267
+ }
268
+ };
269
+ /**
270
+ * After a failed attempt, applies abort rules, `next`, optional delay, and
271
+ * either returns a terminal `RetryRunResult` or `undefined` to continue.
272
+ *
273
+ * @param policy - Resolved retry policy hooks (`next`, `onExhausted`).
274
+ * @param params - Failure context for the current attempt.
275
+ * @param params.attempt - Current attempt number.
276
+ * @param params.error - Error thrown by the attempt.
277
+ * @param params.sleep - Delay function between retries.
278
+ * @param params.signal - Optional abort signal.
279
+ * @param params.logger - Optional logger; logs each retry decision at
280
+ * `debug`, exhaustion at `warn`, and cancellation at `debug`.
281
+ * @returns Terminal non-throw result if the loop should stop, otherwise
282
+ * `undefined` to schedule another attempt.
283
+ * @throws {Error} Any error thrown by `next`, `onExhausted`, or a custom `sleep` when
284
+ * the error is not an abort.
285
+ */
286
+ const handleFailure = async (policy, params) => {
287
+ const { attempt, error, sleep, signal, logger } = params;
288
+ const abortResult = buildAbortResult(signal, attempt, logger);
289
+ if (abortResult !== void 0) return abortResult;
290
+ const decision = policy.next({
291
+ attempt,
292
+ error
293
+ });
294
+ logRetryDecision(logger, attempt, decision, error);
295
+ if (!decision.shouldRetry) return {
296
+ attempts: attempt,
297
+ error: policy.onExhausted({
298
+ attempts: attempt,
299
+ error
300
+ }),
301
+ ok: false
302
+ };
303
+ if (decision.delayMs > 0) {
304
+ const delayAbortResult = await waitForDelay(sleep, decision.delayMs, signal, attempt, logger);
305
+ if (delayAbortResult !== void 0) return delayAbortResult;
306
+ }
307
+ };
308
+ /**
309
+ * Runs the non-throw retry loop, returning
310
+ * `RetryRunResult`.
311
+ *
312
+ * @param policy - Resolved retry policy providing `next` and `onExhausted`.
313
+ * @param execute - Async work callback per attempt.
314
+ * @param sleep - Delay function between retries.
315
+ * @param signal - Optional cancel signal.
316
+ * @param logger - Optional logger; logs each retry decision at `debug`,
317
+ * exhaustion at `warn`, and cancellation at `debug`.
318
+ * @returns Terminal success or failure object. When `policy.isKnownError`
319
+ * rejects a caught value as outside this policy's error domain, the
320
+ * original value is wrapped in a `RetryError` and returned on
321
+ * `result.error` immediately, bypassing retry — never thrown.
322
+ * @throws {Error} Any error thrown by `next`, `onExhausted`, or a non-abort `sleep`
323
+ * failure.
324
+ */
325
+ const runResultMode = async (policy, execute, sleep, signal, logger) => {
326
+ let attempt = 1;
327
+ while (true) {
328
+ const abortResult = buildAbortResult(signal, Math.max(0, attempt - 1), logger);
329
+ if (abortResult !== void 0) return abortResult;
330
+ const execution = await runAttempt(execute, attempt);
331
+ if (execution.ok) return {
332
+ ok: true,
333
+ value: execution.value
334
+ };
335
+ const attemptAbortResult = buildAbortResult(signal, attempt, logger);
336
+ if (attemptAbortResult !== void 0) return attemptAbortResult;
337
+ if (!policy.isKnownError(execution.error)) return {
338
+ attempts: attempt,
339
+ error: new RetryError("Retry policy encountered an unknown error.", {
340
+ attempts: attempt,
341
+ lastError: execution.error
342
+ }),
343
+ ok: false
344
+ };
345
+ const failure = await handleFailure(policy, {
346
+ attempt,
347
+ error: execution.error,
348
+ logger,
349
+ signal,
350
+ sleep
351
+ });
352
+ if (failure !== void 0) return failure;
353
+ attempt += 1;
354
+ }
355
+ };
356
+ /**
357
+ * Default `onExhausted` used when a policy omits it: wraps the exhaustion
358
+ * context in a generic `RetryError`.
359
+ */
360
+ const defaultOnExhausted = (input) => new RetryError("Retry policy exhausted all attempts.", {
361
+ attempts: input.attempts,
362
+ lastData: input.data,
363
+ lastError: input.error
364
+ });
365
+ /**
366
+ * Default `isKnownError` used when a policy omits it: accepts any `Error`
367
+ * instance and rejects everything else.
368
+ */
369
+ const defaultIsKnownError = (error) => error instanceof Error;
370
+ /**
371
+ * Applies `runRetryPolicy`/`runRetryPolicyResult`'s shared defaults
372
+ * (`onExhausted`, `isKnownError`) to a caller-supplied `RetryPolicy`.
373
+ */
374
+ const resolvePolicy = (policy) => ({
375
+ isKnownError: (error) => policy.isKnownError ? policy.isKnownError(error) : defaultIsKnownError(error),
376
+ next: (input) => policy.next(input),
377
+ onExhausted: (input) => policy.onExhausted ? policy.onExhausted(input) : defaultOnExhausted(input)
378
+ });
379
+ /**
380
+ * Runs retry orchestration in non-throw mode.
381
+ *
382
+ * When `throwOnExhausted` is `false`, returns a discriminated result union.
383
+ *
384
+ * @param policy - Retry policy: `next` is required, `onExhausted` and
385
+ * `isKnownError` fall back to their defaults when omitted.
386
+ * @param execute - Async function to execute per attempt.
387
+ * @param options - Runner settings.
388
+ * @returns Success value or terminal result object based on option mode.
389
+ * @throws {Error} Any error thrown by `next`, by `onExhausted`, or by a custom `sleep`
390
+ * function. When `throwOnExhausted` is `false`, exhaustion itself is returned
391
+ * as `{ ok: false }` instead of thrown.
392
+ * Cancellation is returned as `{ ok: false, error: AbortError }` in non-throw
393
+ * mode. A value rejected by `policy.isKnownError` is wrapped in a
394
+ * `RetryError` and returned the same way in non-throw mode; in throw mode
395
+ * it is rethrown as-is.
396
+ *
397
+ * @example
398
+ * const result = await runRetryPolicy(policy, doWork, { throwOnExhausted: false });
399
+ * if (!result.ok) console.error(result.error);
400
+ */
401
+ async function runRetryPolicy(policy, execute, options = {}) {
402
+ const sleep = options.sleep ?? defaultSleep;
403
+ const { signal, logger } = options;
404
+ const resolvedPolicy = resolvePolicy(policy);
405
+ if (options.throwOnExhausted === false) return await runResultMode(resolvedPolicy, execute, sleep, signal, logger);
406
+ return await runThrowMode(resolvedPolicy, execute, sleep, signal, logger);
407
+ }
408
+ /**
409
+ * Runs retry orchestration, returning a `ResultAsync` instead of throwing or
410
+ * returning the hand-rolled {@link RetryRunResult} union.
411
+ *
412
+ * Additive alternative to `runRetryPolicy` for consumers who prefer explicit
413
+ * `Result`/`ResultAsync` values (from `@zap-studio/monads`) over throw/catch.
414
+ * There's no `throwOnExhausted` option — this function always returns a
415
+ * `Result`, so the flag doesn't apply.
416
+ *
417
+ * @param policy - Retry policy: `next` is required, `onExhausted` and
418
+ * `isKnownError` fall back to their defaults when omitted.
419
+ * @param execute - Async function to execute per attempt.
420
+ * @param options - Runner settings, same as {@link RetryRunOptions} minus
421
+ * `throwOnExhausted`.
422
+ * @returns A `ResultAsync` resolving to `Ok` with the successful value, or `Err`
423
+ * with a `RetryError` (exhaustion) or `AbortError` (cancellation) — the same
424
+ * error object `runRetryPolicy`'s throw mode would throw. When
425
+ * `policy.isKnownError` rejects a caught value, it is wrapped in a new
426
+ * `RetryError` and returned on `Err` instead — in throw mode that same value
427
+ * is rethrown unchanged, not wrapped.
428
+ * @throws {Error} Any error thrown by `next`, `onExhausted`, or a custom `sleep`
429
+ * function.
430
+ *
431
+ * @example
432
+ * ```ts
433
+ * import { runRetryPolicyResult } from "@zap-studio/retry";
434
+ *
435
+ * const result = await runRetryPolicyResult(policy, async () => fetchFlakyResource());
436
+ *
437
+ * if (isOk(result)) {
438
+ * console.log(result.value);
439
+ * } else {
440
+ * console.error(result.error);
441
+ * }
442
+ * ```
443
+ */
444
+ const runRetryPolicyResult = (policy, execute, options = {}) => new ResultAsync((async () => {
445
+ const sleep = options.sleep ?? defaultSleep;
446
+ const { signal, logger } = options;
447
+ const resolvedPolicy = resolvePolicy(policy);
448
+ const result = await runResultMode(resolvedPolicy, execute, sleep, signal, logger);
449
+ return result.ok ? ok(result.value) : err(result.error);
450
+ })());
451
+ //#endregion
452
+ export { runRetryPolicy as n, runRetryPolicyResult as r, defaultSleep as t };
453
+
454
+ //# sourceMappingURL=base-policy-DkxBg9dh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base-policy-DkxBg9dh.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 type { Logger } from \"@zap-studio/logger\";\nimport type { Result } from \"@zap-studio/monads\";\n\nimport { trace } from \"@opentelemetry/api\";\nimport { err, ok, ResultAsync } from \"@zap-studio/monads\";\n\nimport type {\n ResolvedRetryPolicy,\n RetryDecision,\n RetryExhaustedInput,\n RetryPolicy,\n RetryRunOptions,\n RetryRunResult,\n RetryRunResultOptions,\n} from \"./types.ts\";\n\nimport { recordRetryAttempt } from \"./_otel.ts\";\nimport { AbortError, RetryError } from \"./errors.ts\";\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 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 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 return await execute(attempt);\n } catch (error) {\n throwIfAborted(signal, logger);\n\n if (!policy.isKnownError(error)) {\n throw error;\n }\n\n await handleThrowModeRetry(policy, { attempt, error, logger, signal, sleep });\n attempt += 1;\n }\n }\n};\n\n/**\n * After a failed, known-domain attempt in throw mode, applies the policy's\n * retry decision: throws the terminal error from `onExhausted` on\n * exhaustion, otherwise waits out the retry delay so the caller's loop can\n * continue.\n *\n * @param policy - Resolved retry policy providing `next` and `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, already known to the policy's domain.\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 `debug`\n * and exhaustion at `warn`.\n * @throws {RetryError} When retries are exhausted and `onExhausted` returns\n * the terminal error.\n * @throws {AbortError} When `signal` aborts while waiting for the retry delay.\n * @throws {Error} Any error thrown by `next`, `onExhausted`, or `sleep`.\n */\nconst handleThrowModeRetry = 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<void> => {\n const { attempt, error, sleep, signal, logger } = params;\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 await (signal === undefined\n ? sleep(decision.delayMs)\n : sleepWithAbortSignal(sleep, decision.delayMs, signal));\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(sleep, decision.delayMs, signal, attempt, logger);\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(signal, Math.max(0, attempt - 1), logger);\n if (abortResult !== undefined) {\n return abortResult;\n }\n\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 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 */\nconst defaultIsKnownError = <TError extends Error>(error: unknown): error is TError =>\n error instanceof Error;\n\n/**\n * Applies `runRetryPolicy`/`runRetryPolicyResult`'s shared defaults\n * (`onExhausted`, `isKnownError`) to a caller-supplied `RetryPolicy`.\n */\nconst resolvePolicy = <TError extends Error, TData>(\n policy: RetryPolicy<TError, TData>,\n): ResolvedRetryPolicy<TError, TData> => ({\n isKnownError: (error): error is TError =>\n policy.isKnownError ? policy.isKnownError(error) : defaultIsKnownError(error),\n next: (input) => policy.next(input),\n onExhausted: (input) =>\n policy.onExhausted ? policy.onExhausted(input) : defaultOnExhausted(input),\n});\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<T, TError extends Error = Error, TData = unknown>(\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<T, TError extends Error = Error, TData = unknown>(\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<T, TError extends Error = Error, TData = unknown>(\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 = resolvePolicy(policy);\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\n/**\n * Runs retry orchestration, returning a `ResultAsync` instead of throwing or\n * returning the hand-rolled {@link RetryRunResult} union.\n *\n * Additive alternative to `runRetryPolicy` for consumers who prefer explicit\n * `Result`/`ResultAsync` values (from `@zap-studio/monads`) over throw/catch.\n * There's no `throwOnExhausted` option — this function always returns a\n * `Result`, so the flag doesn't apply.\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, same as {@link RetryRunOptions} minus\n * `throwOnExhausted`.\n * @returns A `ResultAsync` resolving to `Ok` with the successful value, or `Err`\n * with a `RetryError` (exhaustion) or `AbortError` (cancellation) — the same\n * error object `runRetryPolicy`'s throw mode would throw. When\n * `policy.isKnownError` rejects a caught value, it is wrapped in a new\n * `RetryError` and returned on `Err` instead — in throw mode that same value\n * is rethrown unchanged, not wrapped.\n * @throws {Error} Any error thrown by `next`, `onExhausted`, or a custom `sleep`\n * function.\n *\n * @example\n * ```ts\n * import { runRetryPolicyResult } from \"@zap-studio/retry\";\n *\n * const result = await runRetryPolicyResult(policy, async () => fetchFlakyResource());\n *\n * if (isOk(result)) {\n * console.log(result.value);\n * } else {\n * console.error(result.error);\n * }\n * ```\n */\nexport const runRetryPolicyResult = <T, TError extends Error = Error, TData = unknown>(\n policy: RetryPolicy<TError, TData>,\n execute: (attempt: number) => Promise<T>,\n options: RetryRunResultOptions = {},\n): ResultAsync<T, RetryError | AbortError> =>\n new ResultAsync(\n (async (): Promise<Result<T, RetryError | AbortError>> => {\n const sleep = options.sleep ?? defaultSleep;\n const { signal, logger } = options;\n const resolvedPolicy = resolvePolicy(policy);\n\n const result = await runResultMode(resolvedPolicy, execute, sleep, signal, logger);\n\n return result.ok ? ok(result.value) : err(result.error);\n })(),\n );\n"],"mappings":"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"}