Skip to content

Commit 8094a33

Browse files
authored
feat: add proactiveRetry (#13)
1 parent c4c5e6e commit 8094a33

File tree

3 files changed

+170
-0
lines changed

3 files changed

+170
-0
lines changed

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Abortable async function primitives and combinators.
1515
- [`forever`](#forever)
1616
- [`spawn`](#spawn)
1717
- [`retry`](#retry)
18+
- [`proactiveRetry`](#proactive-retry)
1819
- [`execute`](#execute)
1920
- [`abortable`](#abortable)
2021
- [`run`](#run)
@@ -450,6 +451,64 @@ Retry a function with exponential backoff.
450451

451452
Rethrow error from this callback to prevent further retries.
452453

454+
### `proactiveRetry`
455+
456+
```ts
457+
function proactiveRetry<T>(
458+
signal: AbortSignal,
459+
fn: (signal: AbortSignal, attempt: number) => Promise<T>,
460+
options?: ProactiveRetryOptions,
461+
): Promise<T>;
462+
463+
type ProactiveRetryOptions = {
464+
baseMs?: number;
465+
maxAttempts?: number;
466+
onError?: (error: unknown, attempt: number) => void;
467+
};
468+
```
469+
470+
Proactively retry a function with exponential backoff.
471+
472+
Also known as hedging.
473+
474+
The function will be called multiple times in parallel until it succeeds, in
475+
which case all the other calls will be aborted.
476+
477+
- `fn`
478+
479+
A function that will be called multiple times in parallel until it succeeds.
480+
It receives:
481+
482+
- `signal`
483+
484+
`AbortSignal` that is aborted when the signal passed to `retry` is aborted,
485+
or when the function succeeds.
486+
487+
- `attempt`
488+
489+
Attempt number starting with 0.
490+
491+
- `ProactiveRetryOptions.baseMs`
492+
493+
Base delay between attempts in milliseconds.
494+
495+
Defaults to 1000.
496+
497+
Example: if `baseMs` is 100, then retries will be attempted in 100ms, 200ms,
498+
400ms etc (not counting jitter).
499+
500+
- `ProactiveRetryOptions.maxAttempts`
501+
502+
Maximum for the total number of attempts.
503+
504+
Defaults to `Infinity`.
505+
506+
- `ProactiveRetryOptions.onError`
507+
508+
Called after each failed attempt.
509+
510+
Rethrow error from this callback to prevent further retries.
511+
453512
### `execute`
454513

455514
```ts

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from './race';
99
export * from './retry';
1010
export * from './spawn';
1111
export * from './run';
12+
export * from './proactiveRetry';

src/proactiveRetry.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {isAbortError, catchAbortError} from './AbortError';
2+
import {delay} from './delay';
3+
import {execute} from './execute';
4+
5+
export type ProactiveRetryOptions = {
6+
/**
7+
* Base delay between attempts in milliseconds.
8+
*
9+
* Defaults to 1000.
10+
*
11+
* Example: if `baseMs` is 100, then retries will be attempted in 100ms,
12+
* 200ms, 400ms etc (not counting jitter).
13+
*/
14+
baseMs?: number;
15+
/**
16+
* Maximum for the total number of attempts.
17+
*
18+
* Defaults to `Infinity`.
19+
*/
20+
maxAttempts?: number;
21+
/**
22+
* Called after each failed attempt.
23+
*
24+
* Rethrow error from this callback to prevent further retries.
25+
*/
26+
onError?: (error: unknown, attempt: number) => void;
27+
};
28+
29+
/**
30+
* Proactively retry a function with exponential backoff.
31+
*
32+
* Also known as hedging.
33+
*
34+
* The function will be called multiple times in parallel until it succeeds, in
35+
* which case all the other calls will be aborted.
36+
*/
37+
export function proactiveRetry<T>(
38+
signal: AbortSignal,
39+
fn: (signal: AbortSignal, attempt: number) => Promise<T>,
40+
options: ProactiveRetryOptions = {},
41+
): Promise<T> {
42+
const {baseMs = 1000, onError, maxAttempts = Infinity} = options;
43+
44+
return execute(signal, (resolve, reject) => {
45+
const innerAbortController = new AbortController();
46+
let attemptsExhausted = false;
47+
48+
const promises = new Map</* attempt */ number, Promise<T>>();
49+
50+
function handleFulfilled(value: T) {
51+
innerAbortController.abort();
52+
promises.clear();
53+
54+
resolve(value);
55+
}
56+
57+
function handleRejected(err: unknown, attempt: number) {
58+
promises.delete(attempt);
59+
60+
if (attemptsExhausted && promises.size === 0) {
61+
reject(err);
62+
63+
return;
64+
}
65+
66+
if (isAbortError(err)) {
67+
return;
68+
}
69+
70+
if (onError) {
71+
try {
72+
onError(err, attempt);
73+
} catch (err) {
74+
innerAbortController.abort();
75+
promises.clear();
76+
77+
reject(err);
78+
}
79+
}
80+
}
81+
82+
async function makeAttempts(signal: AbortSignal) {
83+
for (let attempt = 0; ; attempt++) {
84+
const promise = fn(signal, attempt);
85+
86+
promises.set(attempt, promise);
87+
88+
promise.then(handleFulfilled, err => handleRejected(err, attempt));
89+
90+
if (attempt + 1 >= maxAttempts) {
91+
break;
92+
}
93+
94+
// https://aws.amazon.com/ru/blogs/architecture/exponential-backoff-and-jitter/
95+
const backoff = Math.pow(2, attempt) * baseMs;
96+
const delayMs = Math.round((backoff * (1 + Math.random())) / 2);
97+
98+
await delay(signal, delayMs);
99+
}
100+
101+
attemptsExhausted = true;
102+
}
103+
104+
makeAttempts(innerAbortController.signal).catch(catchAbortError);
105+
106+
return () => {
107+
innerAbortController.abort();
108+
};
109+
});
110+
}

0 commit comments

Comments
 (0)