@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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/index.d.ts +81 -0
  4. package/index.js +179 -0
  5. 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
+ }