@sdxc/backoff 0.0.0-pre.1

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 ADDED
@@ -0,0 +1,162 @@
1
+ # @sdxc/backoff
2
+
3
+ Retry delay schedules with exponential, linear, constant or stepped growth, a ceiling, free attempts and seedable jitter.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm add @sdxc/backoff
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### A Growing Delay
14
+
15
+ `delay(attempt)` answers milliseconds to wait after `attempt` failures, counting from 1.
16
+
17
+ ```typescript
18
+ import { createBackoff } from "@sdxc/backoff";
19
+
20
+ let backoff = createBackoff({ base: "15 seconds", max: "12 hours", jitter: 0.2 });
21
+
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
25
+ ```
26
+
27
+ Without jitter, the same schedule is `Math.min(15_000 * 2 ** (attempt - 1), 43_200_000)`.
28
+
29
+ ### A Stored Next Attempt
30
+
31
+ `at(attempt, now)` is `now + delay(attempt)`, for a row that records when to try again.
32
+
33
+ ```typescript
34
+ let nextAttemptAt = backoff.at(failureCount, Date.now());
35
+ ```
36
+
37
+ ### A Hand-Written Table
38
+
39
+ `steps` replaces the curve. The last step repeats for every attempt past the end.
40
+
41
+ ```typescript
42
+ let backoff = createBackoff({
43
+ steps: ["15 seconds", "1 minute", "5 minutes", "30 minutes", "2 hours"],
44
+ jitter: 0.2,
45
+ });
46
+ ```
47
+
48
+ ### Free Attempts
49
+
50
+ `free` attempts answer `0`, and the curve starts on the failure after them.
51
+
52
+ ```typescript
53
+ let lockout = createBackoff({ free: 3, base: "1 second", max: "15 minutes" });
54
+
55
+ lockout.delay(3); // 0
56
+ lockout.delay(4); // 1_000
57
+ lockout.delay(5); // 2_000
58
+ ```
59
+
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
+ ## API
71
+
72
+ ### `createBackoff(options: BackoffOptions): Backoff`
73
+
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"`.
77
+
78
+ A curve schedule takes:
79
+
80
+ - `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`
82
+ - `factor`: the exponential multiplier, `2` by default
83
+ - `max`: the ceiling before jitter; unbounded when omitted
84
+
85
+ A stepped schedule takes `steps`, one delay per counted failure.
86
+
87
+ Both take:
88
+
89
+ - `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.
91
+ - `random`: the stream jitter draws from, `systemRandom()` by default
92
+
93
+ ### `Backoff`
94
+
95
+ - `delay(attempt)`: milliseconds after `attempt` failures; `0` for a non-positive or free attempt
96
+ - `at(attempt, now)`: `now + delay(attempt)`
97
+
98
+ ### `BackoffOptions`, `BackoffCurveOptions`, `BackoffStepsOptions`, `BackoffSharedOptions` and `Jitter`
99
+
100
+ The option shapes above. `Jitter` is `number | "full"`.
101
+
102
+ ## Pattern: Retrying A Queue Message
103
+
104
+ A queue consumer passes its delivery count straight in, so early retries come quickly and a receiver that stays down is left alone.
105
+
106
+ ```typescript
107
+ import { createBackoff } from "@sdxc/backoff";
108
+
109
+ const deliveryBackoff = createBackoff({ base: "1 minute", max: "6 hours", jitter: 0.2 });
110
+
111
+ export default {
112
+ async queue(batch: MessageBatch<{ url: string }>) {
113
+ for (let message of batch.messages) {
114
+ let response = await fetch(message.body.url, { method: "POST" });
115
+ if (response.ok) message.ack();
116
+ else
117
+ message.retry({ delaySeconds: Math.ceil(deliveryBackoff.delay(message.attempts) / 1000) });
118
+ }
119
+ },
120
+ };
121
+ ```
122
+
123
+ ## Pattern: Honoring Retry-After
124
+
125
+ When a server says how long to wait, wait at least that long.
126
+
127
+ ```typescript
128
+ import { createBackoff } from "@sdxc/backoff";
129
+
130
+ let backoff = createBackoff({ base: "1 second", max: "5 minutes", jitter: "full" });
131
+
132
+ function waitAfter(response: Response, attempt: number): number {
133
+ let retryAfterMs = Number(response.headers.get("retry-after") ?? 0) * 1000;
134
+ return Math.max(retryAfterMs, backoff.delay(attempt));
135
+ }
136
+ ```
137
+
138
+ ## Versioning
139
+
140
+ 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.
141
+
142
+ 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.
143
+
144
+ Depend on one exact date, and move it when you are ready to take the change:
145
+
146
+ ```json
147
+ {
148
+ "dependencies": {
149
+ "@sdxc/backoff": "2026.9.4"
150
+ }
151
+ }
152
+ ```
153
+
154
+ 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.
155
+
156
+ ## License
157
+
158
+ MIT
159
+
160
+ ## Author
161
+
162
+ [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 ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@sdxc/backoff",
3
+ "version": "0.0.0-pre.1",
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.2",
12
+ "@sdxc/random": "0.0.0-pre.1"
13
+ },
14
+ "gitHead": "780589b65576d3aa4014c6bcd448b33b4931705a",
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
+ }