@sdxc/backoff 0.0.0-pre.1 → 0.0.0-stage
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 +2 -161
- package/package.json +5 -22
- package/LICENSE.md +0 -21
- package/dist/backoff.d.ts +0 -77
- package/dist/backoff.js +0 -80
- package/dist/index.d.ts +0 -10
- package/dist/index.js +0 -1
package/README.md
CHANGED
|
@@ -1,162 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Temporary Holding Version
|
|
2
2
|
|
|
3
|
-
|
|
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)
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,23 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
}
|
|
2
|
+
"name": "@sdxc/backoff",
|
|
3
|
+
"version": "0.0.0-stage",
|
|
4
|
+
"stub": true,
|
|
5
|
+
"description": "Temporary package placeholder for staged publishing"
|
|
6
|
+
}
|
package/LICENSE.md
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
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/dist/backoff.d.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
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;
|
package/dist/backoff.js
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/index.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
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
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { createBackoff } from "./backoff.js";
|