@zap-studio/retry 1.0.0 → 1.2.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,20 @@ 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
+ ## [1.2.0]
8
+
9
+ ### Added
10
+
11
+ `runRetryPolicy(...)` gains an optional `logger?: Logger` option (from `@zap-studio/logger`). When provided, it logs each retry decision at `debug` (attempt, delay, reason), exhaustion at `warn`, and cancellation at `debug`. Omitting it keeps zero logging overhead. See [Logging](https://www.zapstudio.dev/retry/logging).
12
+
13
+ ## [1.1.0]
14
+
15
+ ### Added
16
+
17
+ `exponentialBackoff(...)` and `linearBackoff(...)` gain an optional `jitter?: "full" | "equal" | JitterOptions` option, applied to the computed delay after it's capped at `maxDelayMs`. `"full"` randomizes across `[0, delayMs]`; `"equal"` keeps a floor at half the delay, randomizing across `[delayMs/2, delayMs]`. Pass `{ mode, random }` to override the random source, useful for deterministic tests.
18
+
19
+ New export: `applyJitter(delayMs, jitter?)`, also available from the `./jitter` subpath. See [Jitter](https://www.zapstudio.dev/retry/jitter).
20
+
7
21
  ## [1.0.0]
8
22
 
9
23
  ### Added
package/README.md CHANGED
@@ -13,31 +13,40 @@ npm install @zap-studio/retry
13
13
  ## Features
14
14
 
15
15
  - **Built-in policies**: `fixedDelay(...)`, `linearBackoff(...)`, and `exponentialBackoff(...)`.
16
+ - **Jitter**: `"full"` or `"equal"` jitter on `exponentialBackoff`/`linearBackoff`, to avoid synchronized retries against a shared upstream.
16
17
  - **A shared runner** via `runRetryPolicy(policy, execute, options?)` with attempt-aware callbacks and custom sleep injection.
17
18
  - **Structured terminal errors**: `RetryError` on exhaustion, `AbortError` on cancellation.
18
19
  - **Non-throw mode** (`throwOnExhausted: false`) returns a `RetryRunResult` instead of throwing.
19
20
  - **Cancellation** through `AbortSignal`, checked before, between, and during retries.
20
21
  - **Custom policies** as plain objects implementing `RetryPolicy` — just a `next(...)` function, no subclassing.
22
+ - **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.
21
23
  - **Tree-shakeable** — policies are functions returning plain objects, not classes; unused policies are dropped by any modern bundler.
22
24
 
23
25
  ## Quick Start
24
26
 
25
27
  ```ts
28
+ import { ConsoleLogger } from "@zap-studio/logger";
26
29
  import { exponentialBackoff, runRetryPolicy } from "@zap-studio/retry";
27
30
  import { $fetch } from "@zap-studio/fetch";
28
31
 
32
+ const logger = new ConsoleLogger({ minLevel: "debug" });
33
+
29
34
  const policy = exponentialBackoff({
30
35
  maxAttempts: 5,
31
36
  baseDelayMs: 100,
32
37
  maxDelayMs: 2_000,
33
38
  });
34
39
 
35
- const data = await runRetryPolicy(policy, async () => {
36
- const response = await $fetch("https://api.example.com/users", {
37
- throwOnFetchError: true,
38
- });
39
- return await response.json();
40
- });
40
+ const data = await runRetryPolicy(
41
+ policy,
42
+ async () => {
43
+ const response = await $fetch("https://api.example.com/users", {
44
+ throwOnFetchError: true,
45
+ });
46
+ return await response.json();
47
+ },
48
+ { logger }
49
+ );
41
50
  ```
42
51
 
43
52
  ## Built-in Policies
@@ -65,6 +74,19 @@ const linear = linearBackoff({
65
74
  const fixed = fixedDelay({ maxAttempts: 4, delayMs: 300 });
66
75
  ```
67
76
 
77
+ ## Jitter
78
+
79
+ `"full"` or `"equal"` jitter on `exponentialBackoff`/`linearBackoff`, applied to the delay after it's capped, to avoid synchronized retries against a shared upstream.
80
+
81
+ ```ts
82
+ const policy = exponentialBackoff({
83
+ maxAttempts: 5,
84
+ baseDelayMs: 100,
85
+ maxDelayMs: 2_000,
86
+ jitter: "full",
87
+ });
88
+ ```
89
+
68
90
  ## Shared Runner
69
91
 
70
92
  Via `runRetryPolicy(policy, execute, options?)` with attempt-aware callbacks and custom sleep injection.
@@ -147,6 +169,22 @@ const stepDelay = (maxAttempts: number, stepMs: number): RetryPolicy => ({
147
169
  const data = await runRetryPolicy(stepDelay(5, 100), execute);
148
170
  ```
149
171
 
172
+ ## Logging
173
+
174
+ Pass a `logger?: Logger` from [`@zap-studio/logger`](https://www.npmjs.com/package/@zap-studio/logger) to `runRetryPolicy(...)` to observe retry decisions, exhaustion, and cancellation. Omit it and nothing is logged.
175
+
176
+ ```ts
177
+ import { ConsoleLogger } from "@zap-studio/logger";
178
+ import { exponentialBackoff, runRetryPolicy } from "@zap-studio/retry";
179
+
180
+ const logger = new ConsoleLogger({ minLevel: "debug" });
181
+ const policy = exponentialBackoff({ maxAttempts: 5, baseDelayMs: 100 });
182
+
183
+ await runRetryPolicy(policy, execute, { logger });
184
+ ```
185
+
186
+ Each retry decision logs at `debug` (attempt, delay, reason), exhaustion logs at `warn`, and cancellation logs at `debug`.
187
+
150
188
  ## Runtime Support
151
189
 
152
190
  | Runtime | Minimum version |
@@ -1 +1 @@
1
- {"version":3,"file":"base-policy.d.ts","names":[],"sources":["../src/base-policy.ts"],"mappings":";;;;;;;;;;;;;;;cA4Ba,eAAsB,oBAAkB;;;;;;;;;;;;;iBA8YrC,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":";;;;;;;;;;;;;;;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,11 +1,6 @@
1
1
  import { AbortError, RetryError } from "./errors.js";
2
2
  //#region src/base-policy.ts
3
3
  /**
4
- * Retry runner base class and shared orchestration implementation.
5
- *
6
- * @module @zap-studio/retry/base-policy
7
- */
8
- /**
9
4
  * Awaits a timer-based delay, unless `delayMs` is non-positive.
10
5
  *
11
6
  * @param delayMs - Milliseconds to wait before resolving.
@@ -42,10 +37,12 @@ const toAbortError = (reason) => {
42
37
  * Throws when the provided abort signal is already aborted.
43
38
  *
44
39
  * @param signal - Optional abort signal to inspect.
40
+ * @param logger - Optional logger; logs the abort at `debug` before throwing.
45
41
  * @throws {AbortError} When the signal is aborted.
46
42
  */
47
- const throwIfAborted = (signal) => {
43
+ const throwIfAborted = (signal, logger) => {
48
44
  if (signal?.aborted !== true) return;
45
+ logger?.debug("retry aborted", { reason: signal.reason });
49
46
  throw toAbortError(signal.reason);
50
47
  };
51
48
  /**
@@ -72,6 +69,25 @@ const sleepWithAbortSignal = async (sleep, delayMs, signal) => {
72
69
  }
73
70
  };
74
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
+ /**
75
91
  * Runs the throw-mode retry loop: throws `RetryError` on exhaustion and
76
92
  * `AbortError` when `signal` aborts.
77
93
  *
@@ -79,6 +95,8 @@ const sleepWithAbortSignal = async (sleep, delayMs, signal) => {
79
95
  * @param execute - Async work callback per attempt.
80
96
  * @param sleep - Delay function between retries.
81
97
  * @param signal - Optional cancel signal.
98
+ * @param logger - Optional logger; logs each retry decision at `debug` and
99
+ * exhaustion at `warn`.
82
100
  * @returns Resolves to the first successful return value.
83
101
  * @throws {RetryError} When retries are exhausted and `onExhausted` returns
84
102
  * the terminal error.
@@ -87,19 +105,20 @@ const sleepWithAbortSignal = async (sleep, delayMs, signal) => {
87
105
  * rethrows the original caught value immediately, bypassing retry, when
88
106
  * `policy.isKnownError` rejects it as outside this policy's error domain.
89
107
  */
90
- const runThrowMode = async (policy, execute, sleep, signal) => {
108
+ const runThrowMode = async (policy, execute, sleep, signal, logger) => {
91
109
  let attempt = 1;
92
110
  while (true) {
93
- throwIfAborted(signal);
111
+ throwIfAborted(signal, logger);
94
112
  try {
95
113
  return await execute(attempt);
96
114
  } catch (error) {
97
- throwIfAborted(signal);
115
+ throwIfAborted(signal, logger);
98
116
  if (!policy.isKnownError(error)) throw error;
99
117
  const decision = policy.next({
100
118
  attempt,
101
119
  error
102
120
  });
121
+ logRetryDecision(logger, attempt, decision, error);
103
122
  if (!decision.shouldRetry) throw policy.onExhausted({
104
123
  attempts: attempt,
105
124
  error
@@ -115,10 +134,12 @@ const runThrowMode = async (policy, execute, sleep, signal) => {
115
134
  *
116
135
  * @param signal - Optional abort signal; only acts when `aborted` is set.
117
136
  * @param attempts - Number of finished attempts to report in the result.
137
+ * @param logger - Optional logger; logs the abort at `debug`.
118
138
  * @returns Failure result or `undefined` if not aborted.
119
139
  */
120
- const buildAbortResult = (signal, attempts) => {
140
+ const buildAbortResult = (signal, attempts, logger) => {
121
141
  if (signal?.aborted !== true) return;
142
+ logger?.debug("retry aborted", { reason: signal.reason });
122
143
  return {
123
144
  attempts,
124
145
  error: toAbortError(signal.reason),
@@ -154,11 +175,12 @@ const runAttempt = async (execute, attempt) => {
154
175
  * @param delayMs - Milliseconds to wait.
155
176
  * @param signal - If set, `sleep` is raced with the abort signal.
156
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`.
157
179
  * @returns A terminal result when canceled during the wait, otherwise
158
180
  * `undefined`.
159
181
  * @throws {Error} The underlying `sleep` rejection when it is not an abort.
160
182
  */
161
- const waitForDelay = async (sleep, delayMs, signal, attempts) => {
183
+ const waitForDelay = async (sleep, delayMs, signal, attempts, logger) => {
162
184
  if (signal === void 0) {
163
185
  await sleep(delayMs);
164
186
  return;
@@ -167,7 +189,7 @@ const waitForDelay = async (sleep, delayMs, signal, attempts) => {
167
189
  await sleepWithAbortSignal(sleep, delayMs, signal);
168
190
  return;
169
191
  } catch (error) {
170
- const aborted = buildAbortResult(signal, attempts);
192
+ const aborted = buildAbortResult(signal, attempts, logger);
171
193
  if (aborted !== void 0) return aborted;
172
194
  throw error;
173
195
  }
@@ -182,19 +204,22 @@ const waitForDelay = async (sleep, delayMs, signal, attempts) => {
182
204
  * @param params.error - Error thrown by the attempt.
183
205
  * @param params.sleep - Delay function between retries.
184
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`.
185
209
  * @returns Terminal non-throw result if the loop should stop, otherwise
186
210
  * `undefined` to schedule another attempt.
187
211
  * @throws {Error} Any error thrown by `next`, `onExhausted`, or a custom `sleep` when
188
212
  * the error is not an abort.
189
213
  */
190
214
  const handleFailure = async (policy, params) => {
191
- const { attempt, error, sleep, signal } = params;
192
- const abortResult = buildAbortResult(signal, attempt);
215
+ const { attempt, error, sleep, signal, logger } = params;
216
+ const abortResult = buildAbortResult(signal, attempt, logger);
193
217
  if (abortResult !== void 0) return abortResult;
194
218
  const decision = policy.next({
195
219
  attempt,
196
220
  error
197
221
  });
222
+ logRetryDecision(logger, attempt, decision, error);
198
223
  if (!decision.shouldRetry) return {
199
224
  attempts: attempt,
200
225
  error: policy.onExhausted({
@@ -204,7 +229,7 @@ const handleFailure = async (policy, params) => {
204
229
  ok: false
205
230
  };
206
231
  if (decision.delayMs > 0) {
207
- const delayAbortResult = await waitForDelay(sleep, decision.delayMs, signal, attempt);
232
+ const delayAbortResult = await waitForDelay(sleep, decision.delayMs, signal, attempt, logger);
208
233
  if (delayAbortResult !== void 0) return delayAbortResult;
209
234
  }
210
235
  };
@@ -216,6 +241,8 @@ const handleFailure = async (policy, params) => {
216
241
  * @param execute - Async work callback per attempt.
217
242
  * @param sleep - Delay function between retries.
218
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`.
219
246
  * @returns Terminal success or failure object. When `policy.isKnownError`
220
247
  * rejects a caught value as outside this policy's error domain, the
221
248
  * original value is wrapped in a `RetryError` and returned on
@@ -223,17 +250,17 @@ const handleFailure = async (policy, params) => {
223
250
  * @throws {Error} Any error thrown by `next`, `onExhausted`, or a non-abort `sleep`
224
251
  * failure.
225
252
  */
226
- const runResultMode = async (policy, execute, sleep, signal) => {
253
+ const runResultMode = async (policy, execute, sleep, signal, logger) => {
227
254
  let attempt = 1;
228
255
  while (true) {
229
- const abortResult = buildAbortResult(signal, Math.max(0, attempt - 1));
256
+ const abortResult = buildAbortResult(signal, Math.max(0, attempt - 1), logger);
230
257
  if (abortResult !== void 0) return abortResult;
231
258
  const execution = await runAttempt(execute, attempt);
232
259
  if (execution.ok) return {
233
260
  ok: true,
234
261
  value: execution.value
235
262
  };
236
- const attemptAbortResult = buildAbortResult(signal, attempt);
263
+ const attemptAbortResult = buildAbortResult(signal, attempt, logger);
237
264
  if (attemptAbortResult !== void 0) return attemptAbortResult;
238
265
  if (!policy.isKnownError(execution.error)) return {
239
266
  attempts: attempt,
@@ -246,6 +273,7 @@ const runResultMode = async (policy, execute, sleep, signal) => {
246
273
  const failure = await handleFailure(policy, {
247
274
  attempt,
248
275
  error: execution.error,
276
+ logger,
249
277
  signal,
250
278
  sleep
251
279
  });
@@ -291,14 +319,14 @@ const defaultIsKnownError = (error) => error instanceof Error;
291
319
  */
292
320
  async function runRetryPolicy(policy, execute, options = {}) {
293
321
  const sleep = options.sleep ?? defaultSleep;
294
- const { signal } = options;
322
+ const { signal, logger } = options;
295
323
  const resolvedPolicy = {
296
324
  isKnownError: (error) => policy.isKnownError ? policy.isKnownError(error) : defaultIsKnownError(error),
297
325
  next: (input) => policy.next(input),
298
326
  onExhausted: (input) => policy.onExhausted ? policy.onExhausted(input) : defaultOnExhausted(input)
299
327
  };
300
- if (options.throwOnExhausted === false) return await runResultMode(resolvedPolicy, execute, sleep, signal);
301
- return await runThrowMode(resolvedPolicy, execute, sleep, signal);
328
+ if (options.throwOnExhausted === false) return await runResultMode(resolvedPolicy, execute, sleep, signal, logger);
329
+ return await runThrowMode(resolvedPolicy, execute, sleep, signal, logger);
302
330
  }
303
331
  //#endregion
304
332
  export { defaultSleep, runRetryPolicy };
@@ -1 +1 @@
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 { AbortError, RetryError } from \"./errors.js\";\nimport type {\n ResolvedRetryPolicy,\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 * @throws {AbortError} When the signal is aborted.\n */\nconst throwIfAborted = (signal?: AbortSignal): void => {\n if (signal?.aborted !== true) {\n return;\n }\n\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 * 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 * @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): Promise<T> => {\n let attempt = 1;\n\n while (true) {\n throwIfAborted(signal);\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);\n\n if (!policy.isKnownError(error)) {\n throw error;\n }\n\n const decision = policy.next({\n attempt,\n error,\n });\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 * @returns Failure result or `undefined` if not aborted.\n */\nconst buildAbortResult = (\n signal: AbortSignal | undefined,\n attempts: number\n): RetryRunResult<never> | undefined => {\n if (signal?.aborted !== true) {\n return undefined;\n }\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 * @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): 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);\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 * @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 }\n): Promise<RetryRunResult<never> | undefined> => {\n const { attempt, error, sleep, signal } = params;\n const abortResult = buildAbortResult(signal, attempt);\n if (abortResult !== undefined) {\n return abortResult;\n }\n\n const decision = policy.next({\n attempt,\n error,\n });\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 );\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 * @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): Promise<RetryRunResult<T>> => {\n let attempt = 1;\n\n while (true) {\n const abortResult = buildAbortResult(signal, Math.max(0, attempt - 1));\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);\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 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 } = 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);\n }\n\n return await runThrowMode(resolvedPolicy, execute, sleep, signal);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA4BA,MAAa,eAAe,OAAO,YAAmC;CACpE,IAAI,WAAW,GACb;CAIF,MAAM,IAAI,SAAe,YAAY;EACnC,WAAW,SAAS,OAAO;CAC7B,CAAC;AACH;;;;AAKA,MAAM,gBAAgB,WAAgC;CACpD,IAAI,kBAAkB,YACpB,OAAO;CAGT,IAAI,kBAAkB,OACpB,OAAO,IAAI,WAAW,OAAO,SAAS,EAAE,OAAO,OAAO,CAAC;CAGzD,IAAI,OAAO,WAAW,YAAY,OAAO,SAAS,GAChD,OAAO,IAAI,WAAW,MAAM;CAG9B,IAAI,WAAW,KAAA,GACb,OAAO,IAAI,WAAW,gBAAgB;CAGxC,IAAI;EACF,OAAO,IAAI,WAAW,kBAAkB,KAAK,UAAU,MAAM,GAAG;CAClE,QAAQ;EACN,OAAO,IAAI,WAAW,gBAAgB;CACxC;AACF;;;;;;;AAQA,MAAM,kBAAkB,WAA+B;CACrD,IAAI,QAAQ,YAAY,MACtB;CAGF,MAAM,aAAa,OAAO,MAAM;AAClC;;;;;;;;;;AAWA,MAAM,uBAAuB,OAC3B,OACA,SACA,WACkB;CAClB,IAAI,OAAO,SACT,MAAM,aAAa,OAAO,MAAM;CAGlC,IAAI;CAEJ,IAAI;EACF,MAAM,QAAQ,KAAK,CACjB,MAAM,OAAO,GAEb,IAAI,SAAgB,UAAU,WAAW;GACvC,gBAAsB;IACpB,OAAO,aAAa,OAAO,MAAM,CAAC;GACpC;GAEA,OAAO,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;EAC1D,CAAC,CACH,CAAC;CACH,UAAU;EACR,IAAI,SACF,OAAO,oBAAoB,SAAS,OAAO;CAE/C;AACF;;;;;;;;;;;;;;;;;AAkBA,MAAM,eAAe,OACnB,QACA,SACA,OACA,WACe;CACf,IAAI,UAAU;CAEd,OAAO,MAAM;EACX,eAAe,MAAM;EAErB,IAAI;GAEF,OAAO,MAAM,QAAQ,OAAO;EAC9B,SAAS,OAAO;GACd,eAAe,MAAM;GAErB,IAAI,CAAC,OAAO,aAAa,KAAK,GAC5B,MAAM;GAGR,MAAM,WAAW,OAAO,KAAK;IAC3B;IACA;GACF,CAAC;GAED,IAAI,CAAC,SAAS,aACZ,MAAM,OAAO,YAAY;IACvB,UAAU;IACV;GACF,CAAC;GAGH,IAAI,SAAS,UAAU,GAErB,OAAO,WAAW,KAAA,IACd,MAAM,SAAS,OAAO,IACtB,qBAAqB,OAAO,SAAS,SAAS,MAAM;GAG1D,WAAW;EACb;CACF;AACF;;;;;;;;;AAUA,MAAM,oBACJ,QACA,aACsC;CACtC,IAAI,QAAQ,YAAY,MACtB;CAGF,OAAO;EACL;EACA,OAAO,aAAa,OAAO,MAAM;EACjC,IAAI;CACN;AACF;;;;;;;;;AAUA,MAAM,aAAa,OACjB,SACA,YACoE;CACpE,IAAI;EACF,OAAO;GACL,IAAI;GACJ,OAAO,MAAM,QAAQ,OAAO;EAC9B;CACF,SAAS,OAAO;EACd,OAAO;GACL;GACA,IAAI;EACN;CACF;AACF;;;;;;;;;;;;;AAcA,MAAM,eAAe,OACnB,OACA,SACA,QACA,aAC+C;CAC/C,IAAI,WAAW,KAAA,GAAW;EACxB,MAAM,MAAM,OAAO;EACnB;CACF;CAEA,IAAI;EACF,MAAM,qBAAqB,OAAO,SAAS,MAAM;EACjD;CACF,SAAS,OAAO;EACd,MAAM,UAAU,iBAAiB,QAAQ,QAAQ;EACjD,IAAI,YAAY,KAAA,GACd,OAAO;EAET,MAAM;CACR;AACF;;;;;;;;;;;;;;;;AAiBA,MAAM,gBAAgB,OACpB,QACA,WAM+C;CAC/C,MAAM,EAAE,SAAS,OAAO,OAAO,WAAW;CAC1C,MAAM,cAAc,iBAAiB,QAAQ,OAAO;CACpD,IAAI,gBAAgB,KAAA,GAClB,OAAO;CAGT,MAAM,WAAW,OAAO,KAAK;EAC3B;EACA;CACF,CAAC;CAED,IAAI,CAAC,SAAS,aAMZ,OAAO;EACL,UAAU;EACV,OAPoB,OAAO,YAAY;GACvC,UAAU;GACV;EACF,CAIqB;EACnB,IAAI;CACN;CAGF,IAAI,SAAS,UAAU,GAAG;EACxB,MAAM,mBAAmB,MAAM,aAC7B,OACA,SAAS,SACT,QACA,OACF;EACA,IAAI,qBAAqB,KAAA,GACvB,OAAO;CAEX;AAGF;;;;;;;;;;;;;;;;AAiBA,MAAM,gBAAgB,OACpB,QACA,SACA,OACA,WAC+B;CAC/B,IAAI,UAAU;CAEd,OAAO,MAAM;EACX,MAAM,cAAc,iBAAiB,QAAQ,KAAK,IAAI,GAAG,UAAU,CAAC,CAAC;EACrE,IAAI,gBAAgB,KAAA,GAClB,OAAO;EAIT,MAAM,YAAY,MAAM,WAAW,SAAS,OAAO;EACnD,IAAI,UAAU,IACZ,OAAO;GAAE,IAAI;GAAM,OAAO,UAAU;EAAM;EAG5C,MAAM,qBAAqB,iBAAiB,QAAQ,OAAO;EAC3D,IAAI,uBAAuB,KAAA,GACzB,OAAO;EAGT,IAAI,CAAC,OAAO,aAAa,UAAU,KAAK,GACtC,OAAO;GACL,UAAU;GACV,OAAO,IAAI,WAAW,8CAA8C;IAClE,UAAU;IACV,WAAW,UAAU;GACvB,CAAC;GACD,IAAI;EACN;EAIF,MAAM,UAAU,MAAM,cAAc,QAAQ;GAC1C;GACA,OAAO,UAAU;GACjB;GACA;EACF,CAAC;EACD,IAAI,YAAY,KAAA,GACd,OAAO;EAGT,WAAW;CACb;AACF;;;;;AAMA,MAAM,sBACJ,UAEA,IAAI,WAAW,wCAAwC;CACrD,UAAU,MAAM;CAChB,UAAU,MAAM;CAChB,WAAW,MAAM;AACnB,CAAC;;;;;AAOH,MAAM,uBACJ,UACoB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;AAsFvC,eAAsB,eAKpB,QACA,SACA,UAA2B,CAAC,GACI;CAChC,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,EAAE,WAAW;CACnB,MAAM,iBAAqD;EACzD,eAAe,UACb,OAAO,eACH,OAAO,aAAa,KAAK,IACzB,oBAAoB,KAAK;EAC/B,OAAO,UAAU,OAAO,KAAK,KAAK;EAClC,cAAc,UACZ,OAAO,cACH,OAAO,YAAY,KAAK,IACxB,mBAAmB,KAAK;CAChC;CAEA,IAAI,QAAQ,qBAAqB,OAC/B,OAAO,MAAM,cAAc,gBAAgB,SAAS,OAAO,MAAM;CAGnE,OAAO,MAAM,aAAa,gBAAgB,SAAS,OAAO,MAAM;AAClE"}
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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"}
@@ -1,4 +1,5 @@
1
1
  import { RetryPolicy } from "./types.js";
2
+ import { JitterMode, JitterOptions } from "./jitter.js";
2
3
  //#region src/exponential-backoff.d.ts
3
4
  /**
4
5
  * Configuration for `exponentialBackoff(...)`.
@@ -8,6 +9,7 @@ import { RetryPolicy } from "./types.js";
8
9
  * maxAttempts: 5,
9
10
  * baseDelayMs: 100,
10
11
  * maxDelayMs: 2_000,
12
+ * jitter: "full",
11
13
  * };
12
14
  */
13
15
  interface ExponentialBackoffOptions {
@@ -23,6 +25,11 @@ interface ExponentialBackoffOptions {
23
25
  * Hard upper bound in milliseconds for computed exponential delay.
24
26
  */
25
27
  maxDelayMs: number;
28
+ /**
29
+ * Optional jitter applied to the computed delay, after capping at
30
+ * `maxDelayMs`.
31
+ */
32
+ jitter?: JitterMode | JitterOptions;
26
33
  }
27
34
  /**
28
35
  * Creates a retry policy with exponential delay growth up to a max cap.
@@ -1 +1 @@
1
- {"version":3,"file":"exponential-backoff.d.ts","names":[],"sources":["../src/exponential-backoff.ts"],"mappings":";;;;;;;;;;;;UAsBiB;;;;EAIf;;;;EAIA;;;;EAIA;;;;;;;;;;;;cAaW,qBACX,SAAS,8BACR"}
1
+ {"version":3,"file":"exponential-backoff.d.ts","names":[],"sources":["../src/exponential-backoff.ts"],"mappings":";;;;;;;;;;;;;;UAyBiB;;;;EAIf;;;;EAIA;;;;EAIA;;;;;EAKA,SAAS,aAAa;;;;;;;;;;;;cAaX,qBACX,SAAS,8BACR"}
@@ -1,5 +1,11 @@
1
+ import { applyJitter } from "./jitter.js";
1
2
  //#region src/exponential-backoff.ts
2
3
  /**
4
+ * Exponential backoff retry strategy.
5
+ *
6
+ * @module @zap-studio/retry/exponential-backoff
7
+ */
8
+ /**
3
9
  * Creates a retry policy with exponential delay growth up to a max cap.
4
10
  *
5
11
  * @example
@@ -10,7 +16,7 @@
10
16
  * });
11
17
  */
12
18
  const exponentialBackoff = (options) => {
13
- const { maxAttempts, baseDelayMs, maxDelayMs } = options;
19
+ const { maxAttempts, baseDelayMs, maxDelayMs, jitter } = options;
14
20
  return {
15
21
  /**
16
22
  * Computes retry decision for the current attempt.
@@ -22,8 +28,9 @@ next(input) {
22
28
  shouldRetry: false
23
29
  };
24
30
  const exponent = Math.max(0, input.attempt - 1);
31
+ const cappedDelayMs = Math.min(maxDelayMs, baseDelayMs * 2 ** exponent);
25
32
  return {
26
- delayMs: Math.min(maxDelayMs, baseDelayMs * 2 ** exponent),
33
+ delayMs: applyJitter(cappedDelayMs, jitter),
27
34
  reason: "retry",
28
35
  shouldRetry: true
29
36
  };
@@ -1 +1 @@
1
- {"version":3,"file":"exponential-backoff.js","names":[],"sources":["../src/exponential-backoff.ts"],"sourcesContent":["/**\n * Exponential backoff retry strategy.\n *\n * @module @zap-studio/retry/exponential-backoff\n */\n\nimport type {\n RetryDecision,\n RetryDecisionInput,\n RetryPolicy,\n} from \"./types.js\";\n\n/**\n * Configuration for `exponentialBackoff(...)`.\n *\n * @example\n * const options: ExponentialBackoffOptions = {\n * maxAttempts: 5,\n * baseDelayMs: 100,\n * maxDelayMs: 2_000,\n * };\n */\nexport interface ExponentialBackoffOptions {\n /**\n * Maximum number of attempts (including the first) before giving up.\n */\n maxAttempts: number;\n /**\n * Initial delay in milliseconds, doubled each retry until capped.\n */\n baseDelayMs: number;\n /**\n * Hard upper bound in milliseconds for computed exponential delay.\n */\n maxDelayMs: number;\n}\n\n/**\n * Creates a retry policy with exponential delay growth up to a max cap.\n *\n * @example\n * const policy = exponentialBackoff({\n * maxAttempts: 5,\n * baseDelayMs: 100,\n * maxDelayMs: 2_000,\n * });\n */\nexport const exponentialBackoff = (\n options: ExponentialBackoffOptions\n): RetryPolicy => {\n const { maxAttempts, baseDelayMs, maxDelayMs } = options;\n\n return {\n /**\n * Computes retry decision for the current attempt.\n */\n next(input: RetryDecisionInput): RetryDecision {\n if (input.attempt >= maxAttempts) {\n return {\n delayMs: 0,\n reason: \"max-attempts-reached\",\n shouldRetry: false,\n };\n }\n\n const exponent = Math.max(0, input.attempt - 1);\n const delayMs = Math.min(maxDelayMs, baseDelayMs * 2 ** exponent);\n\n return { delayMs, reason: \"retry\", shouldRetry: true };\n },\n };\n};\n"],"mappings":";;;;;;;;;;;AA+CA,MAAa,sBACX,YACgB;CAChB,MAAM,EAAE,aAAa,aAAa,eAAe;CAEjD,OAAO;;;;AAIL,KAAK,OAA0C;EAC7C,IAAI,MAAM,WAAW,aACnB,OAAO;GACL,SAAS;GACT,QAAQ;GACR,aAAa;EACf;EAGF,MAAM,WAAW,KAAK,IAAI,GAAG,MAAM,UAAU,CAAC;EAG9C,OAAO;GAAE,SAFO,KAAK,IAAI,YAAY,cAAc,KAAK,QAEzC;GAAG,QAAQ;GAAS,aAAa;EAAK;CACvD,EACF;AACF"}
1
+ {"version":3,"file":"exponential-backoff.js","names":[],"sources":["../src/exponential-backoff.ts"],"sourcesContent":["/**\n * Exponential backoff retry strategy.\n *\n * @module @zap-studio/retry/exponential-backoff\n */\n\nimport { applyJitter } from \"./jitter.js\";\nimport type { JitterMode, JitterOptions } from \"./jitter.js\";\nimport type {\n RetryDecision,\n RetryDecisionInput,\n RetryPolicy,\n} from \"./types.js\";\n\n/**\n * Configuration for `exponentialBackoff(...)`.\n *\n * @example\n * const options: ExponentialBackoffOptions = {\n * maxAttempts: 5,\n * baseDelayMs: 100,\n * maxDelayMs: 2_000,\n * jitter: \"full\",\n * };\n */\nexport interface ExponentialBackoffOptions {\n /**\n * Maximum number of attempts (including the first) before giving up.\n */\n maxAttempts: number;\n /**\n * Initial delay in milliseconds, doubled each retry until capped.\n */\n baseDelayMs: number;\n /**\n * Hard upper bound in milliseconds for computed exponential delay.\n */\n maxDelayMs: number;\n /**\n * Optional jitter applied to the computed delay, after capping at\n * `maxDelayMs`.\n */\n jitter?: JitterMode | JitterOptions;\n}\n\n/**\n * Creates a retry policy with exponential delay growth up to a max cap.\n *\n * @example\n * const policy = exponentialBackoff({\n * maxAttempts: 5,\n * baseDelayMs: 100,\n * maxDelayMs: 2_000,\n * });\n */\nexport const exponentialBackoff = (\n options: ExponentialBackoffOptions\n): RetryPolicy => {\n const { maxAttempts, baseDelayMs, maxDelayMs, jitter } = options;\n\n return {\n /**\n * Computes retry decision for the current attempt.\n */\n next(input: RetryDecisionInput): RetryDecision {\n if (input.attempt >= maxAttempts) {\n return {\n delayMs: 0,\n reason: \"max-attempts-reached\",\n shouldRetry: false,\n };\n }\n\n const exponent = Math.max(0, input.attempt - 1);\n const cappedDelayMs = Math.min(maxDelayMs, baseDelayMs * 2 ** exponent);\n const delayMs = applyJitter(cappedDelayMs, jitter);\n\n return { delayMs, reason: \"retry\", shouldRetry: true };\n },\n };\n};\n"],"mappings":";;;;;;;;;;;;;;;;;AAuDA,MAAa,sBACX,YACgB;CAChB,MAAM,EAAE,aAAa,aAAa,YAAY,WAAW;CAEzD,OAAO;;;;AAIL,KAAK,OAA0C;EAC7C,IAAI,MAAM,WAAW,aACnB,OAAO;GACL,SAAS;GACT,QAAQ;GACR,aAAa;EACf;EAGF,MAAM,WAAW,KAAK,IAAI,GAAG,MAAM,UAAU,CAAC;EAC9C,MAAM,gBAAgB,KAAK,IAAI,YAAY,cAAc,KAAK,QAAQ;EAGtE,OAAO;GAAE,SAFO,YAAY,eAAe,MAE5B;GAAG,QAAQ;GAAS,aAAa;EAAK;CACvD,EACF;AACF"}
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { i as RetryErrorContext, n as AbortErrorContext, r as RetryError, t as AbortError } from "./errors-CS5UPJWs.js";
2
2
  import { RetryDecision, RetryDecisionInput, RetryExhaustedInput, RetryPolicy, RetryRunOptions, RetryRunResult } from "./types.js";
3
3
  import { defaultSleep, runRetryPolicy } from "./base-policy.js";
4
+ import { JitterMode, JitterOptions, applyJitter } from "./jitter.js";
4
5
  import { ExponentialBackoffOptions, exponentialBackoff } from "./exponential-backoff.js";
5
6
  import { FixedDelayOptions, fixedDelay } from "./fixed-delay.js";
6
7
  import { LinearBackoffOptions, linearBackoff } from "./linear-backoff.js";
7
- export { AbortError, type AbortErrorContext, type ExponentialBackoffOptions, type FixedDelayOptions, type LinearBackoffOptions, type RetryDecision, type RetryDecisionInput, RetryError, type RetryErrorContext, type RetryExhaustedInput, type RetryPolicy, type RetryRunOptions, type RetryRunResult, defaultSleep, exponentialBackoff, fixedDelay, linearBackoff, runRetryPolicy };
8
+ export { AbortError, type AbortErrorContext, type ExponentialBackoffOptions, type FixedDelayOptions, type JitterMode, type JitterOptions, type LinearBackoffOptions, type RetryDecision, type RetryDecisionInput, RetryError, type RetryErrorContext, type RetryExhaustedInput, type RetryPolicy, type RetryRunOptions, type RetryRunResult, applyJitter, defaultSleep, exponentialBackoff, fixedDelay, linearBackoff, runRetryPolicy };
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { AbortError, RetryError } from "./errors.js";
2
2
  import { defaultSleep, runRetryPolicy } from "./base-policy.js";
3
+ import { applyJitter } from "./jitter.js";
3
4
  import { exponentialBackoff } from "./exponential-backoff.js";
4
5
  import { fixedDelay } from "./fixed-delay.js";
5
6
  import { linearBackoff } from "./linear-backoff.js";
6
- export { AbortError, RetryError, defaultSleep, exponentialBackoff, fixedDelay, linearBackoff, runRetryPolicy };
7
+ export { AbortError, RetryError, applyJitter, defaultSleep, exponentialBackoff, fixedDelay, linearBackoff, runRetryPolicy };
@@ -0,0 +1,48 @@
1
+ //#region src/jitter.d.ts
2
+ /**
3
+ * Jitter strategies applied to a computed backoff delay.
4
+ *
5
+ * @module @zap-studio/retry/jitter
6
+ */
7
+ /**
8
+ * Supported jitter strategies.
9
+ *
10
+ * - `"full"`: `random(0, delayMs)` — max spread, best thundering-herd
11
+ * protection.
12
+ * - `"equal"`: `delayMs/2 + random(0, delayMs/2)` — keeps a floor at half
13
+ * the computed delay, less spread than full jitter.
14
+ */
15
+ type JitterMode = "equal" | "full";
16
+ /**
17
+ * Configuration for jitter application.
18
+ *
19
+ * @example
20
+ * const jitter: JitterOptions = { mode: "full" };
21
+ */
22
+ interface JitterOptions {
23
+ /**
24
+ * Jitter strategy to apply.
25
+ */
26
+ mode: JitterMode;
27
+ /**
28
+ * Random source in `[0, 1)`, overridable for deterministic tests.
29
+ *
30
+ * @default Math.random
31
+ */
32
+ random?: () => number;
33
+ }
34
+ /**
35
+ * Applies a jitter strategy to a computed delay.
36
+ *
37
+ * @param delayMs - Delay in milliseconds before jitter.
38
+ * @param jitter - Jitter mode shorthand, full `JitterOptions`, or `undefined`
39
+ * to leave `delayMs` untouched.
40
+ * @returns Jittered delay in milliseconds, rounded to the nearest integer.
41
+ *
42
+ * @example
43
+ * const delayMs = applyJitter(1000, "full"); // 0-1000
44
+ */
45
+ declare const applyJitter: (delayMs: number, jitter?: JitterMode | JitterOptions) => number;
46
+ //#endregion
47
+ export { JitterMode, JitterOptions, applyJitter };
48
+ //# sourceMappingURL=jitter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jitter.d.ts","names":[],"sources":["../src/jitter.ts"],"mappings":";;;;;;;;;;;;;;KAcY;;;;;;;UAQK;;;;EAIf,MAAM;;;;;;EAMN;;;;;;;;;;;;;cAcW,cACX,iBACA,SAAS,aAAa"}
package/dist/jitter.js ADDED
@@ -0,0 +1,24 @@
1
+ //#region src/jitter.ts
2
+ /**
3
+ * Applies a jitter strategy to a computed delay.
4
+ *
5
+ * @param delayMs - Delay in milliseconds before jitter.
6
+ * @param jitter - Jitter mode shorthand, full `JitterOptions`, or `undefined`
7
+ * to leave `delayMs` untouched.
8
+ * @returns Jittered delay in milliseconds, rounded to the nearest integer.
9
+ *
10
+ * @example
11
+ * const delayMs = applyJitter(1000, "full"); // 0-1000
12
+ */
13
+ const applyJitter = (delayMs, jitter) => {
14
+ if (jitter === void 0) return delayMs;
15
+ const mode = typeof jitter === "string" ? jitter : jitter.mode;
16
+ const random = (typeof jitter === "string" ? void 0 : jitter.random) ?? Math.random;
17
+ if (mode === "full") return Math.round(random() * delayMs);
18
+ const half = delayMs / 2;
19
+ return Math.round(half + random() * half);
20
+ };
21
+ //#endregion
22
+ export { applyJitter };
23
+
24
+ //# sourceMappingURL=jitter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jitter.js","names":[],"sources":["../src/jitter.ts"],"sourcesContent":["/**\n * Jitter strategies applied to a computed backoff delay.\n *\n * @module @zap-studio/retry/jitter\n */\n\n/**\n * Supported jitter strategies.\n *\n * - `\"full\"`: `random(0, delayMs)` — max spread, best thundering-herd\n * protection.\n * - `\"equal\"`: `delayMs/2 + random(0, delayMs/2)` — keeps a floor at half\n * the computed delay, less spread than full jitter.\n */\nexport type JitterMode = \"equal\" | \"full\";\n\n/**\n * Configuration for jitter application.\n *\n * @example\n * const jitter: JitterOptions = { mode: \"full\" };\n */\nexport interface JitterOptions {\n /**\n * Jitter strategy to apply.\n */\n mode: JitterMode;\n /**\n * Random source in `[0, 1)`, overridable for deterministic tests.\n *\n * @default Math.random\n */\n random?: () => number;\n}\n\n/**\n * Applies a jitter strategy to a computed delay.\n *\n * @param delayMs - Delay in milliseconds before jitter.\n * @param jitter - Jitter mode shorthand, full `JitterOptions`, or `undefined`\n * to leave `delayMs` untouched.\n * @returns Jittered delay in milliseconds, rounded to the nearest integer.\n *\n * @example\n * const delayMs = applyJitter(1000, \"full\"); // 0-1000\n */\nexport const applyJitter = (\n delayMs: number,\n jitter?: JitterMode | JitterOptions\n): number => {\n if (jitter === undefined) {\n return delayMs;\n }\n\n const mode = typeof jitter === \"string\" ? jitter : jitter.mode;\n const random =\n (typeof jitter === \"string\" ? undefined : jitter.random) ?? Math.random;\n\n if (mode === \"full\") {\n return Math.round(random() * delayMs);\n }\n\n const half = delayMs / 2;\n return Math.round(half + random() * half);\n};\n"],"mappings":";;;;;;;;;;;;AA8CA,MAAa,eACX,SACA,WACW;CACX,IAAI,WAAW,KAAA,GACb,OAAO;CAGT,MAAM,OAAO,OAAO,WAAW,WAAW,SAAS,OAAO;CAC1D,MAAM,UACH,OAAO,WAAW,WAAW,KAAA,IAAY,OAAO,WAAW,KAAK;CAEnE,IAAI,SAAS,QACX,OAAO,KAAK,MAAM,OAAO,IAAI,OAAO;CAGtC,MAAM,OAAO,UAAU;CACvB,OAAO,KAAK,MAAM,OAAO,OAAO,IAAI,IAAI;AAC1C"}
@@ -1,4 +1,5 @@
1
1
  import { RetryPolicy } from "./types.js";
2
+ import { JitterMode, JitterOptions } from "./jitter.js";
2
3
  //#region src/linear-backoff.d.ts
3
4
  /**
4
5
  * Configuration for `linearBackoff(...)`.
@@ -9,6 +10,7 @@ import { RetryPolicy } from "./types.js";
9
10
  * baseDelayMs: 100,
10
11
  * incrementMs: 100,
11
12
  * maxDelayMs: 2_000,
13
+ * jitter: "equal",
12
14
  * };
13
15
  */
14
16
  interface LinearBackoffOptions {
@@ -28,6 +30,11 @@ interface LinearBackoffOptions {
28
30
  * Hard upper bound in milliseconds for computed linear delay.
29
31
  */
30
32
  maxDelayMs: number;
33
+ /**
34
+ * Optional jitter applied to the computed delay, after capping at
35
+ * `maxDelayMs`.
36
+ */
37
+ jitter?: JitterMode | JitterOptions;
31
38
  }
32
39
  /**
33
40
  * Creates a retry policy with linear delay growth up to a max cap.
@@ -1 +1 @@
1
- {"version":3,"file":"linear-backoff.d.ts","names":[],"sources":["../src/linear-backoff.ts"],"mappings":";;;;;;;;;;;;;UAuBiB;;;;EAIf;;;;EAIA;;;;EAIA;;;;EAIA;;;;;;;;;;;;;cAcW,gBAAiB,SAAS,yBAAuB"}
1
+ {"version":3,"file":"linear-backoff.d.ts","names":[],"sources":["../src/linear-backoff.ts"],"mappings":";;;;;;;;;;;;;;;UA0BiB;;;;EAIf;;;;EAIA;;;;EAIA;;;;EAIA;;;;;EAKA,SAAS,aAAa;;;;;;;;;;;;;cAcX,gBAAiB,SAAS,yBAAuB"}
@@ -1,5 +1,11 @@
1
+ import { applyJitter } from "./jitter.js";
1
2
  //#region src/linear-backoff.ts
2
3
  /**
4
+ * Linear backoff retry strategy.
5
+ *
6
+ * @module @zap-studio/retry/linear-backoff
7
+ */
8
+ /**
3
9
  * Creates a retry policy with linear delay growth up to a max cap.
4
10
  *
5
11
  * @example
@@ -11,7 +17,7 @@
11
17
  * });
12
18
  */
13
19
  const linearBackoff = (options) => {
14
- const { maxAttempts, baseDelayMs, incrementMs, maxDelayMs } = options;
20
+ const { maxAttempts, baseDelayMs, incrementMs, maxDelayMs, jitter } = options;
15
21
  return {
16
22
  /**
17
23
  * Computes retry decision for the current attempt.
@@ -22,8 +28,9 @@ next(input) {
22
28
  reason: "max-attempts-reached",
23
29
  shouldRetry: false
24
30
  };
31
+ const cappedDelayMs = Math.min(maxDelayMs, baseDelayMs + incrementMs * (input.attempt - 1));
25
32
  return {
26
- delayMs: Math.min(maxDelayMs, baseDelayMs + incrementMs * (input.attempt - 1)),
33
+ delayMs: applyJitter(cappedDelayMs, jitter),
27
34
  reason: "retry",
28
35
  shouldRetry: true
29
36
  };
@@ -1 +1 @@
1
- {"version":3,"file":"linear-backoff.js","names":[],"sources":["../src/linear-backoff.ts"],"sourcesContent":["/**\n * Linear backoff retry strategy.\n *\n * @module @zap-studio/retry/linear-backoff\n */\n\nimport type {\n RetryDecision,\n RetryDecisionInput,\n RetryPolicy,\n} from \"./types.js\";\n\n/**\n * Configuration for `linearBackoff(...)`.\n *\n * @example\n * const options: LinearBackoffOptions = {\n * maxAttempts: 5,\n * baseDelayMs: 100,\n * incrementMs: 100,\n * maxDelayMs: 2_000,\n * };\n */\nexport interface LinearBackoffOptions {\n /**\n * Maximum number of attempts (including the first) before giving up.\n */\n maxAttempts: number;\n /**\n * Delay in milliseconds after the first failed attempt.\n */\n baseDelayMs: number;\n /**\n * Amount added to the delay for each subsequent retry.\n */\n incrementMs: number;\n /**\n * Hard upper bound in milliseconds for computed linear delay.\n */\n maxDelayMs: number;\n}\n\n/**\n * Creates a retry policy with linear delay growth up to a max cap.\n *\n * @example\n * const policy = linearBackoff({\n * maxAttempts: 5,\n * baseDelayMs: 100,\n * incrementMs: 100,\n * maxDelayMs: 2_000,\n * });\n */\nexport const linearBackoff = (options: LinearBackoffOptions): RetryPolicy => {\n const { maxAttempts, baseDelayMs, incrementMs, maxDelayMs } = options;\n\n return {\n /**\n * Computes retry decision for the current attempt.\n */\n next(input: RetryDecisionInput): RetryDecision {\n if (input.attempt >= maxAttempts) {\n return {\n delayMs: 0,\n reason: \"max-attempts-reached\",\n shouldRetry: false,\n };\n }\n\n const delayMs = Math.min(\n maxDelayMs,\n baseDelayMs + incrementMs * (input.attempt - 1)\n );\n\n return { delayMs, reason: \"retry\", shouldRetry: true };\n },\n };\n};\n"],"mappings":";;;;;;;;;;;;AAqDA,MAAa,iBAAiB,YAA+C;CAC3E,MAAM,EAAE,aAAa,aAAa,aAAa,eAAe;CAE9D,OAAO;;;;AAIL,KAAK,OAA0C;EAC7C,IAAI,MAAM,WAAW,aACnB,OAAO;GACL,SAAS;GACT,QAAQ;GACR,aAAa;EACf;EAQF,OAAO;GAAE,SALO,KAAK,IACnB,YACA,cAAc,eAAe,MAAM,UAAU,EAGhC;GAAG,QAAQ;GAAS,aAAa;EAAK;CACvD,EACF;AACF"}
1
+ {"version":3,"file":"linear-backoff.js","names":[],"sources":["../src/linear-backoff.ts"],"sourcesContent":["/**\n * Linear backoff retry strategy.\n *\n * @module @zap-studio/retry/linear-backoff\n */\n\nimport { applyJitter } from \"./jitter.js\";\nimport type { JitterMode, JitterOptions } from \"./jitter.js\";\nimport type {\n RetryDecision,\n RetryDecisionInput,\n RetryPolicy,\n} from \"./types.js\";\n\n/**\n * Configuration for `linearBackoff(...)`.\n *\n * @example\n * const options: LinearBackoffOptions = {\n * maxAttempts: 5,\n * baseDelayMs: 100,\n * incrementMs: 100,\n * maxDelayMs: 2_000,\n * jitter: \"equal\",\n * };\n */\nexport interface LinearBackoffOptions {\n /**\n * Maximum number of attempts (including the first) before giving up.\n */\n maxAttempts: number;\n /**\n * Delay in milliseconds after the first failed attempt.\n */\n baseDelayMs: number;\n /**\n * Amount added to the delay for each subsequent retry.\n */\n incrementMs: number;\n /**\n * Hard upper bound in milliseconds for computed linear delay.\n */\n maxDelayMs: number;\n /**\n * Optional jitter applied to the computed delay, after capping at\n * `maxDelayMs`.\n */\n jitter?: JitterMode | JitterOptions;\n}\n\n/**\n * Creates a retry policy with linear delay growth up to a max cap.\n *\n * @example\n * const policy = linearBackoff({\n * maxAttempts: 5,\n * baseDelayMs: 100,\n * incrementMs: 100,\n * maxDelayMs: 2_000,\n * });\n */\nexport const linearBackoff = (options: LinearBackoffOptions): RetryPolicy => {\n const { maxAttempts, baseDelayMs, incrementMs, maxDelayMs, jitter } = options;\n\n return {\n /**\n * Computes retry decision for the current attempt.\n */\n next(input: RetryDecisionInput): RetryDecision {\n if (input.attempt >= maxAttempts) {\n return {\n delayMs: 0,\n reason: \"max-attempts-reached\",\n shouldRetry: false,\n };\n }\n\n const cappedDelayMs = Math.min(\n maxDelayMs,\n baseDelayMs + incrementMs * (input.attempt - 1)\n );\n const delayMs = applyJitter(cappedDelayMs, jitter);\n\n return { delayMs, reason: \"retry\", shouldRetry: true };\n },\n };\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;AA6DA,MAAa,iBAAiB,YAA+C;CAC3E,MAAM,EAAE,aAAa,aAAa,aAAa,YAAY,WAAW;CAEtE,OAAO;;;;AAIL,KAAK,OAA0C;EAC7C,IAAI,MAAM,WAAW,aACnB,OAAO;GACL,SAAS;GACT,QAAQ;GACR,aAAa;EACf;EAGF,MAAM,gBAAgB,KAAK,IACzB,YACA,cAAc,eAAe,MAAM,UAAU,EAC/C;EAGA,OAAO;GAAE,SAFO,YAAY,eAAe,MAE5B;GAAG,QAAQ;GAAS,aAAa;EAAK;CACvD,EACF;AACF"}
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { r as RetryError, t as AbortError } from "./errors-CS5UPJWs.js";
2
+ import { Logger } from "@zap-studio/logger";
2
3
  //#region src/types.d.ts
3
4
  /**
4
5
  * Retry policy contract consumed by `runRetryPolicy(...)`.
@@ -47,8 +48,19 @@ interface RetryPolicy<TError extends Error = Error, TData = unknown> {
47
48
  * functions, used internally once `runRetryPolicy` has applied defaults.
48
49
  */
49
50
  interface ResolvedRetryPolicy<TError extends Error, TData> {
51
+ /**
52
+ * Returns the retry decision for a failed attempt.
53
+ */
50
54
  next: RetryPolicy<TError, TData>["next"];
55
+ /**
56
+ * Builds the terminal error used when retries are exhausted. Always
57
+ * present here, unlike on `RetryPolicy` where it's optional.
58
+ */
51
59
  onExhausted: (input: RetryExhaustedInput<TError, TData>) => RetryError;
60
+ /**
61
+ * Narrows a caught `unknown` value into `TError`. Always present here,
62
+ * unlike on `RetryPolicy` where it's optional.
63
+ */
52
64
  isKnownError: (error: unknown) => error is TError;
53
65
  }
54
66
  /**
@@ -146,6 +158,13 @@ interface RetryRunOptions {
146
158
  * @default true
147
159
  */
148
160
  readonly throwOnExhausted?: boolean;
161
+ /**
162
+ * Optional logger for retry internals. When omitted, nothing is logged.
163
+ *
164
+ * Logs each retry decision at `debug`, exhaustion at `warn`, and
165
+ * cancellation at `debug`.
166
+ */
167
+ readonly logger?: Logger;
149
168
  }
150
169
  /**
151
170
  * Result union returned by non-throw runner mode.
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;;;;;;;;UAqBiB,YAAY,eAAe,QAAQ,OAAO;;;;;;EAMzD,OAAO,OAAO,mBAAmB,QAAQ,WAAW;;;;;;;;EAQpD,eAAe,OAAO,oBAAoB,QAAQ,WAAW;;;;;;;;;;;;;EAa7D,gBAAgB,mBAAmB,SAAS;;;;;;UAO7B,oBAAoB,eAAe,OAAO;EACzD,MAAM,YAAY,QAAQ;EAC1B,cAAc,OAAO,oBAAoB,QAAQ,WAAW;EAC5D,eAAe,mBAAmB,SAAS;;;;;;;;UAS5B;;;;;WAKN;;;;WAIA;;;;WAIA;;;;;;;;UASM,mBACf,eAAe,QAAQ,OACvB;;;;WAKS;;;;;WAKA;;;;;WAKA,QAAQ;;;;;WAKR,OAAO;;;;;;;;UASD,oBACf,eAAe,QAAQ,OACvB;;;;WAKS;;;;WAIA,QAAQ;;;;WAIR,OAAO;;;;;;;;UASD;;;;;;WAMN,SAAS,oBAAoB;;;;;;WAM7B,SAAS;;;;;;;;WAQT;;;;;;;;;;;;;KAcC,eAAe;;;;EAKrB;;;;EAIA,OAAO;;;;;EAMP;;;;;EAKA,OAAO,aAAa;;;;EAIpB"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;;;;;;;;;UAuBiB,YAAY,eAAe,QAAQ,OAAO;;;;;;EAMzD,OAAO,OAAO,mBAAmB,QAAQ,WAAW;;;;;;;;EAQpD,eAAe,OAAO,oBAAoB,QAAQ,WAAW;;;;;;;;;;;;;EAa7D,gBAAgB,mBAAmB,SAAS;;;;;;UAO7B,oBAAoB,eAAe,OAAO;;;;EAIzD,MAAM,YAAY,QAAQ;;;;;EAK1B,cAAc,OAAO,oBAAoB,QAAQ,WAAW;;;;;EAK5D,eAAe,mBAAmB,SAAS;;;;;;;;UAS5B;;;;;WAKN;;;;WAIA;;;;WAIA;;;;;;;;UASM,mBACf,eAAe,QAAQ,OACvB;;;;WAKS;;;;;WAKA;;;;;WAKA,QAAQ;;;;;WAKR,OAAO;;;;;;;;UASD,oBACf,eAAe,QAAQ,OACvB;;;;WAKS;;;;WAIA,QAAQ;;;;WAIR,OAAO;;;;;;;;UASD;;;;;;WAMN,SAAS,oBAAoB;;;;;;WAM7B,SAAS;;;;;;;;WAQT;;;;;;;WAOA,SAAS;;;;;;;;;;;;;KAcR,eAAe;;;;EAKrB;;;;EAIA,OAAO;;;;;EAMP;;;;;EAKA,OAAO,aAAa;;;;EAIpB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zap-studio/retry",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "description": "Composable, tree-shakeable retry policies for resilient async operations.",
6
6
  "keywords": [
@@ -35,6 +35,7 @@
35
35
  "./errors": "./dist/errors.js",
36
36
  "./exponential-backoff": "./dist/exponential-backoff.js",
37
37
  "./fixed-delay": "./dist/fixed-delay.js",
38
+ "./jitter": "./dist/jitter.js",
38
39
  "./linear-backoff": "./dist/linear-backoff.js",
39
40
  "./types": "./dist/types.js",
40
41
  "./package.json": "./package.json"
@@ -42,6 +43,9 @@
42
43
  "publishConfig": {
43
44
  "access": "public"
44
45
  },
46
+ "dependencies": {
47
+ "@zap-studio/logger": "1.0.0"
48
+ },
45
49
  "devDependencies": {
46
50
  "tsdown": "^0.22.14",
47
51
  "typescript": "^7.0.2",