@sdxc/backoff 0.0.0-stage → 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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Xalambrí
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 CHANGED
@@ -1,3 +1,216 @@
1
- # Temporary Holding Version
1
+ # @sdxc/backoff
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Retry delay schedules with exponential, linear, constant or stepped growth, a ceiling, free attempts and seedable jitter.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm add @sdxc/backoff
9
+ ```
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
+
13
+ ## Usage
14
+
15
+ ### Build a schedule
16
+
17
+ `delay(attempt)` answers the milliseconds to wait after `attempt` failures, counting from 1.
18
+
19
+ ```typescript
20
+ import { createBackoff } from "@sdxc/backoff";
21
+
22
+ let backoff = createBackoff({ base: "15 seconds", max: "12 hours" });
23
+
24
+ backoff.delay(1); // 15_000
25
+ backoff.delay(3); // 60_000
26
+ backoff.delay(20); // 43_200_000, the ceiling
27
+ ```
28
+
29
+ That schedule is `Math.min(15_000 * 2 ** (attempt - 1), 43_200_000)`.
30
+
31
+ ### Pick the growth
32
+
33
+ `growth` changes the curve, and `steps` replaces it with a table whose last entry repeats.
34
+
35
+ ```typescript
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
40
+ ```
41
+
42
+ ### Spread retries with jitter
43
+
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.
45
+
46
+ ```typescript
47
+ let backoff = createBackoff({ base: "15 seconds", max: "12 hours", jitter: 0.2 });
48
+
49
+ backoff.delay(1); // between 12_000 and 18_000
50
+ ```
51
+
52
+ ### Allow free attempts
53
+
54
+ The first `free` failures answer `0`, and the curve starts on the failure after them.
55
+
56
+ ```typescript
57
+ let lockout = createBackoff({ free: 3, base: "1 second", max: "15 minutes" });
58
+
59
+ lockout.delay(3); // 0
60
+ lockout.delay(4); // 1_000
61
+ lockout.delay(5); // 2_000
62
+ ```
63
+
64
+ ## API
65
+
66
+ ### `createBackoff(options: BackoffOptions): Backoff`
67
+
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.
69
+
70
+ A curve schedule takes:
71
+
72
+ - `base`: the delay after the first counted failure
73
+ - `growth`: `"exponential"` (default) multiplies by `factor` per failure, `"linear"` adds `base`, `"constant"` repeats `base`
74
+ - `factor`: the exponential multiplier, `2` by default
75
+ - `max`: the ceiling before jitter; unbounded when omitted
76
+
77
+ A stepped schedule takes `steps`, one delay per counted failure. Both take:
78
+
79
+ - `free`: failures that answer `0`, `0` by default
80
+ - `jitter`: a `Jitter`; none when omitted. Jittered delays round to whole milliseconds
81
+ - `random`: the stream jitter draws from, `systemRandom()` by default
82
+
83
+ ### `Backoff`
84
+
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`
91
+
92
+ `BackoffCurveOptions | BackoffStepsOptions`, both extending `BackoffSharedOptions` (`free`, `jitter`, `random`).
93
+
94
+ #### `Jitter`
95
+
96
+ `number | "full"`: a fraction in `[0, 1]`, or a draw from zero to the full delay.
97
+
98
+ ## Pattern: Retrying A Queue Message
99
+
100
+ A queue consumer passes its delivery count straight in, so early retries come quickly and a receiver that stays down is left alone.
101
+
102
+ ```typescript
103
+ import { createBackoff } from "@sdxc/backoff";
104
+
105
+ let deliveryBackoff = createBackoff({ base: "1 minute", max: "6 hours", jitter: 0.2 });
106
+
107
+ export default {
108
+ async queue(batch: MessageBatch<{ url: string }>) {
109
+ for (let message of batch.messages) {
110
+ let response = await fetch(message.body.url, { method: "POST" });
111
+ if (response.ok) message.ack();
112
+ else
113
+ message.retry({ delaySeconds: Math.ceil(deliveryBackoff.delay(message.attempts) / 1000) });
114
+ }
115
+ },
116
+ };
117
+ ```
118
+
119
+ ## Pattern: Retrying A Request And Honoring Retry-After
120
+
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.
122
+
123
+ ```typescript
124
+ import { createBackoff } from "@sdxc/backoff";
125
+
126
+ let backoff = createBackoff({ base: "1 second", max: "5 minutes", jitter: "full" });
127
+
128
+ function retryAfterMs(response: Response, now: number): number {
129
+ let header = response.headers.get("retry-after");
130
+ if (header === null) return 0;
131
+
132
+ let seconds = Number(header);
133
+ if (Number.isFinite(seconds)) return Math.max(seconds * 1000, 0);
134
+
135
+ let date = Date.parse(header);
136
+ return Number.isNaN(date) ? 0 : Math.max(date - now, 0);
137
+ }
138
+
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
+ }
146
+ }
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
+
192
+ ## Versioning
193
+
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.
195
+
196
+ Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.
197
+
198
+ Depend on one exact date, and move it when you are ready to take the change:
199
+
200
+ ```json
201
+ {
202
+ "dependencies": {
203
+ "@sdxc/backoff": "2026.9.4"
204
+ }
205
+ }
206
+ ```
207
+
208
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.
209
+
210
+ ## License
211
+
212
+ MIT
213
+
214
+ ## Author
215
+
216
+ [Sergio Xalambrí](https://sergiodxa.com)
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Retry delay schedules: how long to wait after the Nth failure, from a growth
3
+ * curve or a hand-written table, with a ceiling, free attempts and jitter drawn
4
+ * from an injectable `Random`, so a seeded test asserts exact delays.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { DurationInput } from "@sdxc/duration";
10
+ import type { Random } from "@sdxc/random";
11
+ /** A schedule answering how long to wait after a given number of failures. */
12
+ export interface Backoff {
13
+ /**
14
+ * Milliseconds to wait after `attempt` failures, counting from 1. A
15
+ * non-positive attempt and a free attempt both answer `0`.
16
+ */
17
+ delay(attempt: number): number;
18
+ /** The epoch milliseconds of the next try: `now` plus `delay(attempt)`. */
19
+ at(attempt: number, now: number): number;
20
+ }
21
+ /**
22
+ * How jitter spreads a delay: a fraction in `[0, 1]` draws from
23
+ * `delay × (1 ± fraction)`, and `"full"` draws from `[0, delay]`.
24
+ */
25
+ export type Jitter = number | "full";
26
+ /** Options every schedule accepts, whatever shapes its delays. */
27
+ export interface BackoffSharedOptions {
28
+ /**
29
+ * Failures that answer `0`; the schedule's first delay belongs to the
30
+ * failure right after them.
31
+ * @default 0
32
+ */
33
+ free?: number;
34
+ /** Spread applied after the ceiling, so retries waiting at the cap still spread out. */
35
+ jitter?: Jitter;
36
+ /**
37
+ * The stream jitter draws from; pass a seeded one to make delays exact.
38
+ * @default systemRandom()
39
+ */
40
+ random?: Random;
41
+ }
42
+ /** A schedule computed from a base delay and a growth rule. */
43
+ export interface BackoffCurveOptions extends BackoffSharedOptions {
44
+ /** The delay after the first counted failure. */
45
+ base: DurationInput;
46
+ /**
47
+ * `"exponential"` multiplies by `factor` per failure, `"linear"` adds `base`
48
+ * per failure, and `"constant"` repeats `base`.
49
+ * @default "exponential"
50
+ */
51
+ growth?: "exponential" | "linear" | "constant";
52
+ /**
53
+ * The multiplier per failure of an exponential schedule.
54
+ * @default 2
55
+ */
56
+ factor?: number;
57
+ /** The ceiling a delay reaches before jitter; unbounded when omitted. */
58
+ max?: DurationInput;
59
+ }
60
+ /** A schedule chosen by hand, one step per failure, the last one repeating. */
61
+ export interface BackoffStepsOptions extends BackoffSharedOptions {
62
+ /** The delay after each counted failure, in order. */
63
+ steps: readonly DurationInput[];
64
+ }
65
+ /** Either shape `createBackoff` accepts. */
66
+ export type BackoffOptions = BackoffCurveOptions | BackoffStepsOptions;
67
+ /**
68
+ * Build a schedule. Options are validated once here, so `delay` and `at`
69
+ * never fail afterwards.
70
+ *
71
+ * @throws RangeError for a negative or unparseable base, step or ceiling, a
72
+ * `factor` below 1, a `max` below `base`, a `jitter` outside `[0, 1]`, empty
73
+ * `steps`, or a `free` that is not a non-negative integer.
74
+ * @example createBackoff({ base: "15 seconds", max: "12 hours", jitter: 0.2 }).delay(3); // 60_000 ± 20%
75
+ * @example createBackoff({ steps: ["1 minute", "5 minutes", "1 hour"] }).at(attempts, Date.now());
76
+ */
77
+ export declare function createBackoff(options: BackoffOptions): Backoff;
@@ -0,0 +1,80 @@
1
+ import { toMs } from "@sdxc/duration";
2
+ import { systemRandom } from "@sdxc/random";
3
+ /**
4
+ * Build a schedule. Options are validated once here, so `delay` and `at`
5
+ * never fail afterwards.
6
+ *
7
+ * @throws RangeError for a negative or unparseable base, step or ceiling, a
8
+ * `factor` below 1, a `max` below `base`, a `jitter` outside `[0, 1]`, empty
9
+ * `steps`, or a `free` that is not a non-negative integer.
10
+ * @example createBackoff({ base: "15 seconds", max: "12 hours", jitter: 0.2 }).delay(3); // 60_000 ± 20%
11
+ * @example createBackoff({ steps: ["1 minute", "5 minutes", "1 hour"] }).at(attempts, Date.now());
12
+ */
13
+ export function createBackoff(options) {
14
+ let free = options.free ?? 0;
15
+ if (!Number.isSafeInteger(free) || free < 0) {
16
+ throw new RangeError(`free needs a non-negative integer, received ${free}.`);
17
+ }
18
+ let jitter = options.jitter;
19
+ if (typeof jitter === "number" && !(jitter >= 0 && jitter <= 1)) {
20
+ throw new RangeError(`jitter needs a fraction in [0, 1] or "full", received ${jitter}.`);
21
+ }
22
+ let random = options.random ?? systemRandom();
23
+ let curve = "steps" in options ? stepsCurve(options) : growthCurve(options);
24
+ let backoff = {
25
+ delay(attempt) {
26
+ let counted = attempt - free;
27
+ if (!(counted >= 1))
28
+ return 0;
29
+ let delay = curve(counted);
30
+ if (jitter === undefined || jitter === 0)
31
+ return delay;
32
+ if (jitter === "full")
33
+ return Math.round(random.float(0, delay));
34
+ return Math.round(delay * random.float(1 - jitter, 1 + jitter));
35
+ },
36
+ at(attempt, now) {
37
+ return now + backoff.delay(attempt);
38
+ },
39
+ };
40
+ return backoff;
41
+ }
42
+ /**
43
+ * Convert a duration option to milliseconds, rejecting the negative values
44
+ * and unparseable strings that `toMs` reports as `NaN`.
45
+ */
46
+ function nonNegativeMs(name, input) {
47
+ let ms = toMs(input);
48
+ if (!(ms >= 0))
49
+ throw new RangeError(`${name} needs a non-negative duration, received ${input}.`);
50
+ return ms;
51
+ }
52
+ /**
53
+ * The pre-jitter delay of a growth schedule for a counted failure (1 or more).
54
+ * An unbounded exponential schedule reaches `Infinity` once the product
55
+ * overflows.
56
+ */
57
+ function growthCurve(options) {
58
+ let base = nonNegativeMs("base", options.base);
59
+ let factor = options.factor ?? 2;
60
+ if (!(factor >= 1))
61
+ throw new RangeError(`factor needs to be at least 1, received ${factor}.`);
62
+ let max = options.max === undefined ? Infinity : nonNegativeMs("max", options.max);
63
+ if (max < base)
64
+ throw new RangeError(`max (${max}ms) needs to be at least base (${base}ms).`);
65
+ let growth = options.growth ?? "exponential";
66
+ return (counted) => {
67
+ if (growth === "constant")
68
+ return Math.min(base, max);
69
+ if (growth === "linear")
70
+ return Math.min(base * counted, max);
71
+ return Math.min(base * factor ** (counted - 1), max);
72
+ };
73
+ }
74
+ /** The pre-jitter delay of a stepped schedule, repeating the last step past the end. */
75
+ function stepsCurve(options) {
76
+ if (options.steps.length === 0)
77
+ throw new RangeError("steps needs at least one delay.");
78
+ let steps = options.steps.map((step, index) => nonNegativeMs(`steps[${index}]`, step));
79
+ return (counted) => steps[Math.min(counted, steps.length) - 1];
80
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The package entrypoint: `createBackoff` and the schedule it returns, which
3
+ * answers how long to wait after the Nth failure in milliseconds, plus the
4
+ * option shapes a caller builds one from.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export type { Backoff, BackoffCurveOptions, BackoffOptions, BackoffSharedOptions, BackoffStepsOptions, Jitter, } from "./backoff.js";
10
+ export { createBackoff } from "./backoff.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createBackoff } from "./backoff.js";
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
- "name": "@sdxc/backoff",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@sdxc/backoff",
3
+ "version": "2026.10.13",
4
+ "description": "Retry delay schedules with exponential, linear, constant or stepped growth, a ceiling, free attempts and seedable jitter",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "dependencies": {
11
+ "@sdxc/duration": "2026.10.13",
12
+ "@sdxc/random": "2026.10.13"
13
+ },
14
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/sergiodxa/monorepo.git",
21
+ "directory": "packages/backoff"
22
+ },
23
+ "homepage": "https://sdxc.sergiodxa.com/api/backoff",
24
+ "author": {
25
+ "name": "Sergio Xalambrí",
26
+ "url": "https://sergiodxa.com"
27
+ },
28
+ "funding": {
29
+ "type": "github",
30
+ "url": "https://github.com/sponsors/sergiodxa"
31
+ }
32
+ }