@verifyhash/retry 0.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/LICENSE +21 -0
- package/README.md +145 -0
- package/index.d.ts +81 -0
- package/index.js +179 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 verifyhash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# @verifyhash/retry
|
|
2
|
+
|
|
3
|
+
Zero-dependency async **retry with exponential backoff + jitter** for
|
|
4
|
+
Node.js, typed from birth (hand-written `index.d.ts` ships with the
|
|
5
|
+
package). The backoff model is the AWS-recommended one: the un-jittered
|
|
6
|
+
delay is `min(minDelay * factor^(attempt-1), maxDelay)`, and the default
|
|
7
|
+
`'full'` jitter multiplies it by a uniform random in `[0, 1)` — so 10
|
|
8
|
+
clients that all fail at once do NOT hammer the server again in lockstep.
|
|
9
|
+
|
|
10
|
+
Its distinguishing feature is **determinism without fake timers**: the
|
|
11
|
+
backoff math lives in a pure, exported `computeDelay(attempt, opts, rand)`
|
|
12
|
+
(the random source is an argument), and `retry()` accepts an injectable
|
|
13
|
+
`sleep(ms, signal)`. Your tests pass a stub sleep that records the requested
|
|
14
|
+
delays and resolves immediately, plus a fixed `rand` — every backoff vector
|
|
15
|
+
is exact and the whole suite runs in milliseconds. This library's own
|
|
16
|
+
70-check suite does exactly that (`node test/index.test.js` finishes in
|
|
17
|
+
under 100 ms with zero dev dependencies).
|
|
18
|
+
|
|
19
|
+
**Who it's for:** Node.js developers wrapping flaky calls (HTTP, DNS,
|
|
20
|
+
databases, rate-limited APIs) who want cancellation via a standard
|
|
21
|
+
`AbortSignal` — including **mid-sleep**, where naive implementations keep a
|
|
22
|
+
dead timer pending — and who want to unit-test their retry policy exactly
|
|
23
|
+
instead of sprinkling `jest.useFakeTimers()` around.
|
|
24
|
+
|
|
25
|
+
**Honest limits:** JavaScript cannot interrupt a promise that is already
|
|
26
|
+
running, so aborting does NOT cancel an in-flight `fn()` call — the abort
|
|
27
|
+
takes effect at the next boundary (mid-sleep or before the next attempt).
|
|
28
|
+
If you need the attempt itself cancelled, pass the same signal into `fn`
|
|
29
|
+
(e.g. to `fetch`). There is also no retry budget/deadline option; compose
|
|
30
|
+
one with `shouldRetry` if you need it.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm install @verifyhash/retry
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Example
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const { retry, computeDelay } = require('@verifyhash/retry');
|
|
42
|
+
|
|
43
|
+
// Retry a flaky call up to 5 times: 100ms, 200ms, 400ms, 800ms base delays
|
|
44
|
+
// (each multiplied by Math.random() under the default 'full' jitter).
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const data = await retry(
|
|
47
|
+
(attempt) => fetchThing({ signal: controller.signal, attempt }),
|
|
48
|
+
{
|
|
49
|
+
attempts: 5,
|
|
50
|
+
minDelay: 100,
|
|
51
|
+
maxDelay: 2000, // caps the un-jittered delay
|
|
52
|
+
signal: controller.signal, // abort() rejects promptly, even mid-sleep
|
|
53
|
+
shouldRetry: (err) => err.code !== 'FATAL', // give up on fatal errors
|
|
54
|
+
onRetry: (err, attempt, delay) =>
|
|
55
|
+
console.warn(`attempt ${attempt} failed (${err.message}), retrying in ${delay}ms`),
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// The pure calculator, pinned exactly in tests via an injected rand:
|
|
60
|
+
computeDelay(3, { minDelay: 100, factor: 2, jitter: 'none' }); // 400
|
|
61
|
+
computeDelay(3, { minDelay: 100, factor: 2 }, () => 0.5); // 200 (full jitter, rand=0.5)
|
|
62
|
+
computeDelay(10, { minDelay: 100, maxDelay: 30000, jitter: 'none' }); // 30000 (clamped from 51200)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Deterministic testing pattern (what this library's own suite does — no fake
|
|
66
|
+
timers, no real waiting):
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
const { retry } = require('@verifyhash/retry');
|
|
70
|
+
|
|
71
|
+
const delays = [];
|
|
72
|
+
const stubSleep = (ms) => { delays.push(ms); return Promise.resolve(); };
|
|
73
|
+
|
|
74
|
+
let n = 0;
|
|
75
|
+
const v = await retry(() => { if (++n < 3) throw new Error('boom'); return 42; },
|
|
76
|
+
{ attempts: 5, minDelay: 100, jitter: 'none', sleep: stubSleep });
|
|
77
|
+
// v === 42, delays === [100, 200] — exact, instant.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API
|
|
81
|
+
|
|
82
|
+
### `retry(fn, options?) → Promise`
|
|
83
|
+
|
|
84
|
+
Calls `fn(attempt)` (attempt is 1-based) until it resolves. Resolves with
|
|
85
|
+
`fn`'s value. Rejects with:
|
|
86
|
+
|
|
87
|
+
- the **last error**, once `attempts` are exhausted;
|
|
88
|
+
- the error itself, immediately, when `shouldRetry(error, attempt)` returns
|
|
89
|
+
false (no sleep happens);
|
|
90
|
+
- an **AbortError-shaped error** (`name: 'AbortError'`, `code: 'ABORT_ERR'`)
|
|
91
|
+
when `options.signal` aborts — before the first attempt, mid-sleep (the
|
|
92
|
+
default sleep clears its pending timer), or between attempts. No further
|
|
93
|
+
attempts run after an abort. If the signal was aborted with an `Error`
|
|
94
|
+
reason (Node's default reason is a DOMException named `AbortError`), that
|
|
95
|
+
reason is the rejection, matching `fetch()` semantics.
|
|
96
|
+
|
|
97
|
+
Options (all optional):
|
|
98
|
+
|
|
99
|
+
| option | default | meaning |
|
|
100
|
+
| ------------- | ------------- | ------- |
|
|
101
|
+
| `attempts` | `3` | total tries including the first (integer ≥ 1) |
|
|
102
|
+
| `minDelay` | `100` | base delay in ms before the first retry |
|
|
103
|
+
| `maxDelay` | `30000` | cap on the un-jittered delay (must be ≥ `minDelay`) |
|
|
104
|
+
| `factor` | `2` | exponential base (≥ 1; `1` = flat backoff) |
|
|
105
|
+
| `jitter` | `'full'` | `'full'` = delay × `rand()`; `'none'` = exact |
|
|
106
|
+
| `signal` | — | `AbortSignal`; see abort semantics above |
|
|
107
|
+
| `shouldRetry` | retry all | `(error, attempt) => boolean \| Promise<boolean>` |
|
|
108
|
+
| `onRetry` | — | `(error, attempt, delay)` hook, fired before each sleep |
|
|
109
|
+
| `sleep` | real timer | injectable `(ms, signal) => Promise<void>` |
|
|
110
|
+
| `rand` | `Math.random` | injectable random source in `[0, 1)` for `'full'` jitter |
|
|
111
|
+
|
|
112
|
+
Invalid options (e.g. `attempts: 0`, `jitter: 'half'`, `maxDelay <
|
|
113
|
+
minDelay`) reject with `RangeError`/`TypeError`. Because `fn` cannot be
|
|
114
|
+
interrupted, a value `fn` resolves AFTER an abort is still delivered.
|
|
115
|
+
|
|
116
|
+
### `computeDelay(attempt, options?, rand?) → number`
|
|
117
|
+
|
|
118
|
+
Pure backoff calculator used by `retry` and exported for exact tests and
|
|
119
|
+
custom schedulers: `min(minDelay * factor^(attempt-1), maxDelay)`,
|
|
120
|
+
multiplied by `rand()` when `jitter` is `'full'`. `attempt` is the 1-based
|
|
121
|
+
attempt that just failed (integer ≥ 1, else `RangeError`). Accepts the same
|
|
122
|
+
`minDelay`/`maxDelay`/`factor`/`jitter` options as `retry` and returns
|
|
123
|
+
milliseconds (fractional under `'full'` jitter — e.g. `rand = () => 0.999`
|
|
124
|
+
at attempt 1 gives `99.9`).
|
|
125
|
+
|
|
126
|
+
TypeScript users also get the exported types `RetryOptions`,
|
|
127
|
+
`BackoffOptions`, `Jitter`, `SleepFn`, and `RandomSource`.
|
|
128
|
+
|
|
129
|
+
## How to test
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
cd retry
|
|
133
|
+
node test/index.test.js # 70 checks, zero dependencies, < 100 ms
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The suite pins golden vectors for `computeDelay` (growth, clamp, both jitter
|
|
137
|
+
modes with fixed `rand`), success-after-N-failures, exhaustion,
|
|
138
|
+
`shouldRetry` short-circuit (sync and async), the `onRetry` call log, and
|
|
139
|
+
three abort paths (pre-start, mid-sleep via a stub sleep, and mid-sleep
|
|
140
|
+
through the real default `setTimeout` sleep — aborted synchronously, so even
|
|
141
|
+
that test never actually waits).
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for @verifyhash/retry — zero-dependency async retry with
|
|
3
|
+
* exponential backoff + jitter, deterministic in tests via an injectable
|
|
4
|
+
* sleep(ms, signal) and a pure computeDelay(attempt, opts, rand).
|
|
5
|
+
*
|
|
6
|
+
* Hand-written against index.js — every runtime export is declared here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Jitter mode: 'full' (default, delay = rand() * capped) or 'none' (exact). */
|
|
10
|
+
export type Jitter = 'none' | 'full';
|
|
11
|
+
|
|
12
|
+
/** The pure backoff shape shared by computeDelay() and retry(). */
|
|
13
|
+
export interface BackoffOptions {
|
|
14
|
+
/** Base delay in ms before the first retry. Default 100. */
|
|
15
|
+
minDelay?: number;
|
|
16
|
+
/** Cap in ms applied to the un-jittered exponential delay. Default 30000. */
|
|
17
|
+
maxDelay?: number;
|
|
18
|
+
/** Exponential base: delay grows by factor^(attempt-1). Default 2. */
|
|
19
|
+
factor?: number;
|
|
20
|
+
/** 'full' (default) multiplies the capped delay by rand(); 'none' is exact. */
|
|
21
|
+
jitter?: Jitter;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A random source for 'full' jitter: must return a number in [0, 1). */
|
|
25
|
+
export type RandomSource = () => number;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An injectable sleep. Must resolve after ~ms milliseconds and SHOULD reject
|
|
29
|
+
* promptly (with an AbortError-shaped error) if `signal` aborts first — the
|
|
30
|
+
* default implementation (real setTimeout) does exactly that. Test stubs may
|
|
31
|
+
* simply record `ms` and resolve immediately.
|
|
32
|
+
*/
|
|
33
|
+
export type SleepFn = (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
34
|
+
|
|
35
|
+
/** Options for retry(). */
|
|
36
|
+
export interface RetryOptions extends BackoffOptions {
|
|
37
|
+
/** Total tries including the first (integer >= 1). Default 3. */
|
|
38
|
+
attempts?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Abort: rejects the retry() promise promptly with an AbortError-shaped
|
|
41
|
+
* error (name 'AbortError', code 'ABORT_ERR') — before the first attempt,
|
|
42
|
+
* mid-sleep, or between attempts — and stops all future attempts. An fn()
|
|
43
|
+
* call already in flight is NOT interrupted (JavaScript cannot cancel a
|
|
44
|
+
* running promise); the abort takes effect at the next boundary.
|
|
45
|
+
*/
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
/**
|
|
48
|
+
* Decide whether `error` (from 1-based attempt `attempt`) is retryable.
|
|
49
|
+
* Returning false rejects retry() with that error immediately. May be
|
|
50
|
+
* async. Default: retry every error.
|
|
51
|
+
*/
|
|
52
|
+
shouldRetry?: (error: unknown, attempt: number) => boolean | Promise<boolean>;
|
|
53
|
+
/** Fired before each backoff sleep with the failure, attempt, and delay ms. */
|
|
54
|
+
onRetry?: (error: unknown, attempt: number, delay: number) => void;
|
|
55
|
+
/** Injectable sleep; default is a real setTimeout with abort wiring. */
|
|
56
|
+
sleep?: SleepFn;
|
|
57
|
+
/** Injectable random source for 'full' jitter; default Math.random. */
|
|
58
|
+
rand?: RandomSource;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run `fn` (called with the 1-based attempt number) until it resolves,
|
|
63
|
+
* attempts are exhausted (rejects with the LAST error), shouldRetry returns
|
|
64
|
+
* false (rejects with that error), or `signal` aborts (rejects with an
|
|
65
|
+
* AbortError-shaped error). Resolves with fn's value.
|
|
66
|
+
*/
|
|
67
|
+
export function retry<T>(
|
|
68
|
+
fn: (attempt: number) => T | PromiseLike<T>,
|
|
69
|
+
options?: RetryOptions
|
|
70
|
+
): Promise<T>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pure backoff calculator: min(minDelay * factor^(attempt-1), maxDelay),
|
|
74
|
+
* multiplied by rand() under 'full' jitter. `attempt` is 1-based (integer
|
|
75
|
+
* >= 1, else RangeError). Returns milliseconds, fractional under 'full'.
|
|
76
|
+
*/
|
|
77
|
+
export function computeDelay(
|
|
78
|
+
attempt: number,
|
|
79
|
+
options?: BackoffOptions,
|
|
80
|
+
rand?: RandomSource
|
|
81
|
+
): number;
|
package/index.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @verifyhash/retry — zero-dependency async retry with exponential backoff
|
|
5
|
+
* and jitter, designed to be deterministic in tests WITHOUT fake timers:
|
|
6
|
+
*
|
|
7
|
+
* - computeDelay(attempt, opts, rand) is a PURE function (rand injectable,
|
|
8
|
+
* default Math.random) so every backoff value can be pinned exactly.
|
|
9
|
+
* - retry(fn, opts) accepts an injectable sleep(ms, signal) (default: real
|
|
10
|
+
* setTimeout with AbortSignal wiring), so tests pass a stub sleep that
|
|
11
|
+
* records requested delays and resolves immediately.
|
|
12
|
+
*
|
|
13
|
+
* Backoff model (AWS-style):
|
|
14
|
+
* raw = minDelay * factor^(attempt - 1)
|
|
15
|
+
* capped = min(raw, maxDelay)
|
|
16
|
+
* delay = capped when jitter === 'none'
|
|
17
|
+
* delay = rand() * capped when jitter === 'full' (default)
|
|
18
|
+
*
|
|
19
|
+
* Abort semantics: an aborted signal rejects the retry() promise promptly
|
|
20
|
+
* with an AbortError-shaped error (name 'AbortError', code 'ABORT_ERR') —
|
|
21
|
+
* before the first attempt, mid-sleep (the default sleep clears its timer),
|
|
22
|
+
* or between attempts. LIMIT: JavaScript cannot interrupt a running promise,
|
|
23
|
+
* so an fn() call already in flight is not cancelled; the abort takes effect
|
|
24
|
+
* at the next boundary. If fn() has already resolved, its value is delivered
|
|
25
|
+
* even if the signal aborted meanwhile.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const DEFAULT_ATTEMPTS = 3;
|
|
29
|
+
const DEFAULT_MIN_DELAY = 100;
|
|
30
|
+
const DEFAULT_MAX_DELAY = 30000;
|
|
31
|
+
const DEFAULT_FACTOR = 2;
|
|
32
|
+
const DEFAULT_JITTER = 'full';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the AbortError-shaped rejection for an aborted signal.
|
|
36
|
+
* If the signal was aborted with an Error reason (Node's default reason is a
|
|
37
|
+
* DOMException named 'AbortError', which IS an Error), that reason is used
|
|
38
|
+
* directly — matching fetch() semantics. A non-Error custom reason is kept
|
|
39
|
+
* on the constructed error's `cause`.
|
|
40
|
+
*/
|
|
41
|
+
function makeAbortError(signal) {
|
|
42
|
+
const reason = signal ? signal.reason : undefined;
|
|
43
|
+
if (reason instanceof Error) return reason;
|
|
44
|
+
const err = new Error('The operation was aborted');
|
|
45
|
+
err.name = 'AbortError';
|
|
46
|
+
err.code = 'ABORT_ERR';
|
|
47
|
+
if (reason !== undefined) err.cause = reason;
|
|
48
|
+
return err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Validate + default the backoff-shape options shared by computeDelay/retry. */
|
|
52
|
+
function normalizeBackoff(options) {
|
|
53
|
+
if (options === null || typeof options !== 'object') {
|
|
54
|
+
throw new TypeError('options must be an object');
|
|
55
|
+
}
|
|
56
|
+
const minDelay = options.minDelay === undefined ? DEFAULT_MIN_DELAY : options.minDelay;
|
|
57
|
+
const maxDelay = options.maxDelay === undefined ? DEFAULT_MAX_DELAY : options.maxDelay;
|
|
58
|
+
const factor = options.factor === undefined ? DEFAULT_FACTOR : options.factor;
|
|
59
|
+
const jitter = options.jitter === undefined ? DEFAULT_JITTER : options.jitter;
|
|
60
|
+
|
|
61
|
+
if (typeof minDelay !== 'number' || !Number.isFinite(minDelay) || minDelay < 0) {
|
|
62
|
+
throw new RangeError('minDelay must be a finite number >= 0');
|
|
63
|
+
}
|
|
64
|
+
if (typeof maxDelay !== 'number' || !Number.isFinite(maxDelay) || maxDelay < minDelay) {
|
|
65
|
+
throw new RangeError('maxDelay must be a finite number >= minDelay');
|
|
66
|
+
}
|
|
67
|
+
if (typeof factor !== 'number' || !Number.isFinite(factor) || factor < 1) {
|
|
68
|
+
throw new RangeError('factor must be a finite number >= 1');
|
|
69
|
+
}
|
|
70
|
+
if (jitter !== 'none' && jitter !== 'full') {
|
|
71
|
+
throw new TypeError("jitter must be 'none' or 'full'");
|
|
72
|
+
}
|
|
73
|
+
return { minDelay, maxDelay, factor, jitter };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Pure backoff calculator. `attempt` is 1-based: the delay AFTER attempt N
|
|
78
|
+
* failed (so attempt 1 -> minDelay before the cap/jitter). `rand` must return
|
|
79
|
+
* a number in [0, 1) and defaults to Math.random; inject a fixed function for
|
|
80
|
+
* exact, deterministic vectors in tests.
|
|
81
|
+
*
|
|
82
|
+
* Returns a number of milliseconds (may be fractional under 'full' jitter).
|
|
83
|
+
*/
|
|
84
|
+
function computeDelay(attempt, options = {}, rand = Math.random) {
|
|
85
|
+
if (typeof attempt !== 'number' || !Number.isInteger(attempt) || attempt < 1) {
|
|
86
|
+
throw new RangeError('attempt must be an integer >= 1');
|
|
87
|
+
}
|
|
88
|
+
if (typeof rand !== 'function') {
|
|
89
|
+
throw new TypeError('rand must be a function returning a number in [0, 1)');
|
|
90
|
+
}
|
|
91
|
+
const { minDelay, maxDelay, factor, jitter } = normalizeBackoff(options);
|
|
92
|
+
const raw = minDelay * Math.pow(factor, attempt - 1);
|
|
93
|
+
const capped = Math.min(raw, maxDelay);
|
|
94
|
+
if (jitter === 'none') return capped;
|
|
95
|
+
return rand() * capped; // 'full' jitter: uniform in [0, capped)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Default sleep: real setTimeout with abort wiring. Resolves after `ms`
|
|
100
|
+
* milliseconds, or rejects promptly with an AbortError-shaped error if
|
|
101
|
+
* `signal` aborts first (the pending timer is cleared, so the process does
|
|
102
|
+
* not linger). Also rejects immediately if `signal` is already aborted.
|
|
103
|
+
*/
|
|
104
|
+
function defaultSleep(ms, signal) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
if (signal && signal.aborted) {
|
|
107
|
+
reject(makeAbortError(signal));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const onAbort = () => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
reject(makeAbortError(signal));
|
|
113
|
+
};
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
116
|
+
resolve();
|
|
117
|
+
}, ms);
|
|
118
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Retry `fn` (called with the 1-based attempt number) until it resolves,
|
|
124
|
+
* `attempts` are exhausted (rejects with the LAST error), `shouldRetry`
|
|
125
|
+
* returns false (rejects with that error immediately), or `signal` aborts
|
|
126
|
+
* (rejects with an AbortError-shaped error and never runs another attempt).
|
|
127
|
+
*
|
|
128
|
+
* Options (all optional):
|
|
129
|
+
* attempts total tries including the first (integer >= 1, default 3)
|
|
130
|
+
* minDelay base delay in ms before the first retry (default 100)
|
|
131
|
+
* maxDelay cap in ms on the un-jittered delay (default 30000)
|
|
132
|
+
* factor exponential base (default 2)
|
|
133
|
+
* jitter 'full' (default) | 'none'
|
|
134
|
+
* signal AbortSignal
|
|
135
|
+
* shouldRetry(error, attempt) -> boolean | Promise<boolean> (default: retry all)
|
|
136
|
+
* onRetry(error, attempt, delay) hook fired before each sleep
|
|
137
|
+
* sleep(ms, signal) -> Promise injectable, default real setTimeout
|
|
138
|
+
* rand() -> number in [0, 1) injectable, default Math.random
|
|
139
|
+
*/
|
|
140
|
+
async function retry(fn, options = {}) {
|
|
141
|
+
if (typeof fn !== 'function') {
|
|
142
|
+
throw new TypeError('fn must be a function');
|
|
143
|
+
}
|
|
144
|
+
const backoff = normalizeBackoff(options);
|
|
145
|
+
const attempts = options.attempts === undefined ? DEFAULT_ATTEMPTS : options.attempts;
|
|
146
|
+
if (typeof attempts !== 'number' || !Number.isInteger(attempts) || attempts < 1) {
|
|
147
|
+
throw new RangeError('attempts must be an integer >= 1');
|
|
148
|
+
}
|
|
149
|
+
const signal = options.signal;
|
|
150
|
+
const sleep = options.sleep === undefined ? defaultSleep : options.sleep;
|
|
151
|
+
const rand = options.rand === undefined ? Math.random : options.rand;
|
|
152
|
+
const shouldRetry = options.shouldRetry === undefined ? () => true : options.shouldRetry;
|
|
153
|
+
const onRetry = options.onRetry;
|
|
154
|
+
if (typeof sleep !== 'function') throw new TypeError('sleep must be a function');
|
|
155
|
+
if (typeof rand !== 'function') throw new TypeError('rand must be a function');
|
|
156
|
+
if (typeof shouldRetry !== 'function') throw new TypeError('shouldRetry must be a function');
|
|
157
|
+
if (onRetry !== undefined && typeof onRetry !== 'function') {
|
|
158
|
+
throw new TypeError('onRetry must be a function');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let lastError;
|
|
162
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
163
|
+
if (signal && signal.aborted) throw makeAbortError(signal);
|
|
164
|
+
try {
|
|
165
|
+
return await fn(attempt);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
lastError = err;
|
|
168
|
+
}
|
|
169
|
+
if (signal && signal.aborted) throw makeAbortError(signal);
|
|
170
|
+
if (attempt >= attempts) break; // exhausted: fall through to reject
|
|
171
|
+
if (!(await shouldRetry(lastError, attempt))) break; // short-circuit
|
|
172
|
+
const delay = computeDelay(attempt, backoff, rand);
|
|
173
|
+
if (onRetry) onRetry(lastError, attempt, delay);
|
|
174
|
+
await sleep(delay, signal); // an abort mid-sleep rejects here
|
|
175
|
+
}
|
|
176
|
+
throw lastError;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { retry, computeDelay };
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@verifyhash/retry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-dependency async retry for Node.js with exponential backoff and full jitter (AWS-style), AbortSignal support that cancels even mid-sleep, shouldRetry/onRetry hooks, and injectable sleep + rand so tests are exact and instant without fake-timer libraries.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"retry",
|
|
7
|
+
"backoff",
|
|
8
|
+
"exponential-backoff",
|
|
9
|
+
"jitter",
|
|
10
|
+
"abortsignal",
|
|
11
|
+
"abort",
|
|
12
|
+
"async",
|
|
13
|
+
"promise",
|
|
14
|
+
"zero-dependency",
|
|
15
|
+
"typescript"
|
|
16
|
+
],
|
|
17
|
+
"main": "index.js",
|
|
18
|
+
"types": "index.d.ts",
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node test/index.test.js"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"index.d.ts"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/verifyhash/libs.git",
|
|
35
|
+
"directory": "retry"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/verifyhash/libs/tree/main/retry#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/verifyhash/libs/issues"
|
|
40
|
+
}
|
|
41
|
+
}
|