@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.
Files changed (2) hide show
  1. package/README.md +90 -46
  2. 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
- ```bash
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
- ### A Growing Delay
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", jitter: 0.2 });
22
+ let backoff = createBackoff({ base: "15 seconds", max: "12 hours" });
21
23
 
22
- backoff.delay(1); // 15_000 ±20%
23
- backoff.delay(3); // 60_000 ±20%
24
- backoff.delay(20); // 43_200_000 ±20%: the ceiling, then jitter
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
- Without jitter, the same schedule is `Math.min(15_000 * 2 ** (attempt - 1), 43_200_000)`.
29
+ That schedule is `Math.min(15_000 * 2 ** (attempt - 1), 43_200_000)`.
28
30
 
29
- ### A Stored Next Attempt
31
+ ### Pick the growth
30
32
 
31
- `at(attempt, now)` is `now + delay(attempt)`, for a row that records when to try again.
33
+ `growth` changes the curve, and `steps` replaces it with a table whose last entry repeats.
32
34
 
33
35
  ```typescript
34
- let nextAttemptAt = backoff.at(failureCount, Date.now());
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
- ### A Hand-Written Table
42
+ ### Spread retries with jitter
38
43
 
39
- `steps` replaces the curve. The last step repeats for every attempt past the end.
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
- steps: ["15 seconds", "1 minute", "5 minutes", "30 minutes", "2 hours"],
44
- jitter: 0.2,
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
- ### Free Attempts
52
+ ### Allow free attempts
49
53
 
50
- `free` attempts answer `0`, and the curve starts on the failure after them.
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
- Validate the options once and return the schedule. 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.
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` per failure, `"constant"` repeats `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 fraction `f` draws from `delay × (1 ± f)`; `"full"` draws from `[0, delay]`; omitted means none. Applied after `max`, so retries waiting at the ceiling still spread out. Jittered delays are rounded to whole milliseconds.
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
- ### `BackoffOptions`, `BackoffCurveOptions`, `BackoffStepsOptions`, `BackoffSharedOptions` and `Jitter`
92
+ `BackoffCurveOptions | BackoffStepsOptions`, both extending `BackoffSharedOptions` (`free`, `jitter`, `random`).
99
93
 
100
- The option shapes above. `Jitter` is `number | "full"`.
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
- const deliveryBackoff = createBackoff({ base: "1 minute", max: "6 hours", jitter: 0.2 });
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, so read both, and treat anything else as no answer.
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 waitAfter(response: Response, attempt: number): number {
144
- return Math.max(retryAfterMs(response, Date.now()), backoff.delay(attempt));
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.6",
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.6",
12
- "@sdxc/random": "2026.10.6"
11
+ "@sdxc/duration": "2026.10.13",
12
+ "@sdxc/random": "2026.10.13"
13
13
  },
14
- "gitHead": "b1067eb20fc47ec51ff8c4de47d07dc3f9c00466",
14
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
15
15
  "publishConfig": {
16
16
  "access": "public"
17
17
  },