@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 +16 -0
- package/README.md +46 -13
- package/dist/base-policy-DkxBg9dh.js +454 -0
- package/dist/base-policy-DkxBg9dh.js.map +1 -0
- package/dist/base-policy.d.ts +41 -2
- package/dist/base-policy.d.ts.map +1 -1
- package/dist/base-policy.js +3 -334
- package/dist/errors-CS5UPJWs.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/exponential-backoff.d.ts.map +1 -1
- package/dist/exponential-backoff.js +0 -5
- package/dist/exponential-backoff.js.map +1 -1
- package/dist/fixed-delay.d.ts.map +1 -1
- package/dist/fixed-delay.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/jitter.d.ts.map +1 -1
- package/dist/jitter.js.map +1 -1
- package/dist/linear-backoff.d.ts.map +1 -1
- package/dist/linear-backoff.js +0 -5
- package/dist/linear-backoff.js.map +1 -1
- package/dist/types.d.ts +12 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -4
- package/dist/base-policy.js.map +0 -1
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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"}
|