@sdxc/backoff 2026.10.6 → 2026.10.13
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/README.md +90 -46
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -4,50 +4,54 @@ Retry delay schedules with exponential, linear, constant or stepped growth, a ce
|
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
|
-
```
|
|
7
|
+
```sh
|
|
8
8
|
npm add @sdxc/backoff
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
Durations are [`@sdxc/duration`](https://www.npmjs.com/package/@sdxc/duration) inputs, and jitter draws from a [`@sdxc/random`](https://www.npmjs.com/package/@sdxc/random) stream; install `@sdxc/random` too when you pass a seeded one.
|
|
12
|
+
|
|
11
13
|
## Usage
|
|
12
14
|
|
|
13
|
-
###
|
|
15
|
+
### Build a schedule
|
|
14
16
|
|
|
15
|
-
`delay(attempt)` answers milliseconds to wait after `attempt` failures, counting from 1.
|
|
17
|
+
`delay(attempt)` answers the milliseconds to wait after `attempt` failures, counting from 1.
|
|
16
18
|
|
|
17
19
|
```typescript
|
|
18
20
|
import { createBackoff } from "@sdxc/backoff";
|
|
19
21
|
|
|
20
|
-
let backoff = createBackoff({ base: "15 seconds", max: "12 hours"
|
|
22
|
+
let backoff = createBackoff({ base: "15 seconds", max: "12 hours" });
|
|
21
23
|
|
|
22
|
-
backoff.delay(1); // 15_000
|
|
23
|
-
backoff.delay(3); // 60_000
|
|
24
|
-
backoff.delay(20); // 43_200_000
|
|
24
|
+
backoff.delay(1); // 15_000
|
|
25
|
+
backoff.delay(3); // 60_000
|
|
26
|
+
backoff.delay(20); // 43_200_000, the ceiling
|
|
25
27
|
```
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
That schedule is `Math.min(15_000 * 2 ** (attempt - 1), 43_200_000)`.
|
|
28
30
|
|
|
29
|
-
###
|
|
31
|
+
### Pick the growth
|
|
30
32
|
|
|
31
|
-
`
|
|
33
|
+
`growth` changes the curve, and `steps` replaces it with a table whose last entry repeats.
|
|
32
34
|
|
|
33
35
|
```typescript
|
|
34
|
-
|
|
36
|
+
createBackoff({ base: "1 second", factor: 3 }); // 1s, 3s, 9s, 27s…
|
|
37
|
+
createBackoff({ base: "30 seconds", growth: "linear" }); // 30s, 60s, 90s…
|
|
38
|
+
createBackoff({ base: "5 seconds", growth: "constant" }); // 5s, 5s, 5s…
|
|
39
|
+
createBackoff({ steps: ["15 seconds", "1 minute", "5 minutes", "2 hours"] }); // …2h, 2h
|
|
35
40
|
```
|
|
36
41
|
|
|
37
|
-
###
|
|
42
|
+
### Spread retries with jitter
|
|
38
43
|
|
|
39
|
-
`
|
|
44
|
+
A fraction `f` draws each delay from `delay × (1 ± f)`; `"full"` draws from `[0, delay]`. Jitter applies after `max`, so retries waiting at the ceiling still spread out.
|
|
40
45
|
|
|
41
46
|
```typescript
|
|
42
|
-
let backoff = createBackoff({
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
47
|
+
let backoff = createBackoff({ base: "15 seconds", max: "12 hours", jitter: 0.2 });
|
|
48
|
+
|
|
49
|
+
backoff.delay(1); // between 12_000 and 18_000
|
|
46
50
|
```
|
|
47
51
|
|
|
48
|
-
###
|
|
52
|
+
### Allow free attempts
|
|
49
53
|
|
|
50
|
-
`free`
|
|
54
|
+
The first `free` failures answer `0`, and the curve starts on the failure after them.
|
|
51
55
|
|
|
52
56
|
```typescript
|
|
53
57
|
let lockout = createBackoff({ free: 3, base: "1 second", max: "15 minutes" });
|
|
@@ -57,47 +61,39 @@ lockout.delay(4); // 1_000
|
|
|
57
61
|
lockout.delay(5); // 2_000
|
|
58
62
|
```
|
|
59
63
|
|
|
60
|
-
### Exact Jitter In Tests
|
|
61
|
-
|
|
62
|
-
Jitter draws from a [`@sdxc/random`](https://www.npmjs.com/package/@sdxc/random) stream. Pass a seeded one and every delay is reproducible.
|
|
63
|
-
|
|
64
|
-
```typescript
|
|
65
|
-
import { createRandom } from "@sdxc/random";
|
|
66
|
-
|
|
67
|
-
let backoff = createBackoff({ base: 1000, jitter: 0.2, random: createRandom("test") });
|
|
68
|
-
```
|
|
69
|
-
|
|
70
64
|
## API
|
|
71
65
|
|
|
72
66
|
### `createBackoff(options: BackoffOptions): Backoff`
|
|
73
67
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
Durations are [`@sdxc/duration`](https://www.npmjs.com/package/@sdxc/duration) inputs: milliseconds or strings such as `"30 minutes"`.
|
|
68
|
+
Validates the options once and returns the schedule, so `delay` and `at` never fail. Throws `RangeError` for a negative or unparseable duration, a `factor` below 1, a `max` below `base`, a `jitter` outside `[0, 1]`, empty `steps`, or a `free` that is not a non-negative integer.
|
|
77
69
|
|
|
78
70
|
A curve schedule takes:
|
|
79
71
|
|
|
80
72
|
- `base`: the delay after the first counted failure
|
|
81
|
-
- `growth`: `"exponential"` (default) multiplies by `factor` per failure, `"linear"` adds `base
|
|
73
|
+
- `growth`: `"exponential"` (default) multiplies by `factor` per failure, `"linear"` adds `base`, `"constant"` repeats `base`
|
|
82
74
|
- `factor`: the exponential multiplier, `2` by default
|
|
83
75
|
- `max`: the ceiling before jitter; unbounded when omitted
|
|
84
76
|
|
|
85
|
-
A stepped schedule takes `steps`, one delay per counted failure.
|
|
86
|
-
|
|
87
|
-
Both take:
|
|
77
|
+
A stepped schedule takes `steps`, one delay per counted failure. Both take:
|
|
88
78
|
|
|
89
79
|
- `free`: failures that answer `0`, `0` by default
|
|
90
|
-
- `jitter`: a
|
|
80
|
+
- `jitter`: a `Jitter`; none when omitted. Jittered delays round to whole milliseconds
|
|
91
81
|
- `random`: the stream jitter draws from, `systemRandom()` by default
|
|
92
82
|
|
|
93
83
|
### `Backoff`
|
|
94
84
|
|
|
95
|
-
- `delay(attempt)`: milliseconds after `attempt` failures; `0` for a non-positive or free attempt
|
|
96
|
-
- `at(attempt, now)`: `now + delay(attempt)
|
|
85
|
+
- `delay(attempt: number): number`: milliseconds after `attempt` failures; `0` for a non-positive or free attempt
|
|
86
|
+
- `at(attempt: number, now: number): number`: `now + delay(attempt)`, for storing when to try again
|
|
87
|
+
|
|
88
|
+
### Types
|
|
89
|
+
|
|
90
|
+
#### `BackoffOptions`
|
|
97
91
|
|
|
98
|
-
|
|
92
|
+
`BackoffCurveOptions | BackoffStepsOptions`, both extending `BackoffSharedOptions` (`free`, `jitter`, `random`).
|
|
99
93
|
|
|
100
|
-
|
|
94
|
+
#### `Jitter`
|
|
95
|
+
|
|
96
|
+
`number | "full"`: a fraction in `[0, 1]`, or a draw from zero to the full delay.
|
|
101
97
|
|
|
102
98
|
## Pattern: Retrying A Queue Message
|
|
103
99
|
|
|
@@ -106,7 +102,7 @@ A queue consumer passes its delivery count straight in, so early retries come qu
|
|
|
106
102
|
```typescript
|
|
107
103
|
import { createBackoff } from "@sdxc/backoff";
|
|
108
104
|
|
|
109
|
-
|
|
105
|
+
let deliveryBackoff = createBackoff({ base: "1 minute", max: "6 hours", jitter: 0.2 });
|
|
110
106
|
|
|
111
107
|
export default {
|
|
112
108
|
async queue(batch: MessageBatch<{ url: string }>) {
|
|
@@ -120,9 +116,9 @@ export default {
|
|
|
120
116
|
};
|
|
121
117
|
```
|
|
122
118
|
|
|
123
|
-
## Pattern: Honoring Retry-After
|
|
119
|
+
## Pattern: Retrying A Request And Honoring Retry-After
|
|
124
120
|
|
|
125
|
-
When a server says how long to wait, wait at least that long. `Retry-After` is either a number of seconds or an HTTP date
|
|
121
|
+
When a server says how long to wait, wait at least that long. `Retry-After` is either a number of seconds or an HTTP date; anything else counts as no answer.
|
|
126
122
|
|
|
127
123
|
```typescript
|
|
128
124
|
import { createBackoff } from "@sdxc/backoff";
|
|
@@ -140,11 +136,59 @@ function retryAfterMs(response: Response, now: number): number {
|
|
|
140
136
|
return Number.isNaN(date) ? 0 : Math.max(date - now, 0);
|
|
141
137
|
}
|
|
142
138
|
|
|
143
|
-
function
|
|
144
|
-
|
|
139
|
+
async function fetchWithRetry(url: string, attempts = 5): Promise<Response> {
|
|
140
|
+
for (let attempt = 1; ; attempt++) {
|
|
141
|
+
let response = await fetch(url);
|
|
142
|
+
if (response.ok || attempt === attempts) return response;
|
|
143
|
+
let wait = Math.max(retryAfterMs(response, Date.now()), backoff.delay(attempt));
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
145
|
+
}
|
|
145
146
|
}
|
|
146
147
|
```
|
|
147
148
|
|
|
149
|
+
## Pattern: Locking Out Repeated Sign-In Failures
|
|
150
|
+
|
|
151
|
+
A stored failure count and `at()` give a row the time its next attempt opens. The first three failures cost nothing.
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { createBackoff } from "@sdxc/backoff";
|
|
155
|
+
|
|
156
|
+
let lockout = createBackoff({ free: 3, steps: ["30 seconds", "5 minutes", "1 hour"] });
|
|
157
|
+
|
|
158
|
+
interface SignInState {
|
|
159
|
+
failures: number;
|
|
160
|
+
lockedUntil: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isLocked(state: SignInState, now: number): boolean {
|
|
164
|
+
return state.lockedUntil > now;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function recordFailure(state: SignInState, now: number): SignInState {
|
|
168
|
+
let failures = state.failures + 1;
|
|
169
|
+
return { failures, lockedUntil: lockout.at(failures, now) };
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Pattern: Exact Delays In Tests
|
|
174
|
+
|
|
175
|
+
Pass a seeded `Random` and every jittered delay is reproducible, so a test asserts exact values.
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import { createBackoff } from "@sdxc/backoff";
|
|
179
|
+
import { createRandom } from "@sdxc/random";
|
|
180
|
+
import { expect, test } from "vitest";
|
|
181
|
+
|
|
182
|
+
test("jitter is reproducible", () => {
|
|
183
|
+
let first = createBackoff({ base: 1000, jitter: 0.2, random: createRandom("test") });
|
|
184
|
+
let second = createBackoff({ base: 1000, jitter: 0.2, random: createRandom("test") });
|
|
185
|
+
|
|
186
|
+
expect(first.delay(3)).toBe(second.delay(3));
|
|
187
|
+
expect(first.delay(3)).toBeGreaterThanOrEqual(3200);
|
|
188
|
+
expect(first.delay(3)).toBeLessThanOrEqual(4800);
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
148
192
|
## Versioning
|
|
149
193
|
|
|
150
194
|
Releases are dated rather than semantic. A version is the UTC date it was published, written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one release goes out per day.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/backoff",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.13",
|
|
4
4
|
"description": "Retry delay schedules with exponential, linear, constant or stepped growth, a ceiling, free attempts and seedable jitter",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
".": "./dist/index.js"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@sdxc/duration": "2026.10.
|
|
12
|
-
"@sdxc/random": "2026.10.
|
|
11
|
+
"@sdxc/duration": "2026.10.13",
|
|
12
|
+
"@sdxc/random": "2026.10.13"
|
|
13
13
|
},
|
|
14
|
-
"gitHead": "
|
|
14
|
+
"gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
|
|
15
15
|
"publishConfig": {
|
|
16
16
|
"access": "public"
|
|
17
17
|
},
|