@pico-brief/race-promises 1.0.0
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 +156 -0
- package/dist/racePromises.d.ts +28 -0
- package/dist/racePromises.js +168 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# racePromises
|
|
2
|
+
|
|
3
|
+
## What is this?
|
|
4
|
+
|
|
5
|
+
When you call something slow — like an LLM API — things can go wrong in two ways:
|
|
6
|
+
|
|
7
|
+
1. **It fails** (network error, rate limit, bad response) → you want to **retry**
|
|
8
|
+
2. **It takes too long** → rather than cancelling and waiting, you want to **fire off another call in parallel** and just use whichever one comes back first
|
|
9
|
+
|
|
10
|
+
`racePromises` handles both. You give it a function that creates a promise (your API call), tell it how many attempts to allow and how long to wait before firing off the next one, and it takes care of the rest. The first successful result wins.
|
|
11
|
+
|
|
12
|
+
> **Important:** This pattern works best for **idempotent** operations (e.g. reads, LLM inference). For writes or operations with side effects, launching duplicate requests can cause problems.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Parameters
|
|
17
|
+
|
|
18
|
+
| Parameter | Type | Required | Description |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `generatePromise` | `() => Promise<T>` | Yes | A function that starts your async task and returns a promise. Called once per attempt. |
|
|
21
|
+
| `amount` | `number` | Yes | Maximum number of calls that will ever be started. Must be greater than 0. Set to `1` for a single attempt with no retries. |
|
|
22
|
+
| `waitTimeSeconds` | `number` | Yes | How long to wait (in **seconds**) after starting a call before firing the next one in parallel. Use `0` to launch all attempts simultaneously. |
|
|
23
|
+
| `shouldRetry` | `(e: any) => boolean` | No | Called when a promise rejects. Return `false` to stop all attempts immediately. Defaults to always `true`. |
|
|
24
|
+
| `onBackgroundError` | `(e: any) => void` | No | Called when a promise rejects *after* the function has already returned a result. Use this to avoid unhandled rejection warnings. See notes below. |
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Start call #1
|
|
32
|
+
│
|
|
33
|
+
├── Succeeds? → ✅ Return result
|
|
34
|
+
│
|
|
35
|
+
├── shouldRetry(e) === false? → 🛑 Stop everything, throw RetryAbortedError
|
|
36
|
+
│
|
|
37
|
+
└── No result yet, and either waitTimeSeconds has elapsed
|
|
38
|
+
OR all current attempts have already failed?
|
|
39
|
+
│
|
|
40
|
+
Start next call in parallel (previous calls still running!)
|
|
41
|
+
│
|
|
42
|
+
└── Whoever resolves first wins
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The next call starts as soon as *either* condition is met — whichever comes first. You don't always wait the full `waitTimeSeconds` if all current attempts have already failed.
|
|
46
|
+
|
|
47
|
+
**If every attempt fails**, throws an `AggregateError` — inspect `e.errors` for each individual rejection reason.
|
|
48
|
+
|
|
49
|
+
**If `shouldRetry` returns `false`**, throws a `RetryAbortedError` immediately — even if other attempts are still in-flight. The triggering error is attached as `.cause`.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
### Basic example
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import racePromises from './racePromises';
|
|
59
|
+
|
|
60
|
+
const result = await racePromises({
|
|
61
|
+
generatePromise: () => callMyLLM(prompt),
|
|
62
|
+
amount: 3, // fire at most 3 calls total
|
|
63
|
+
waitTimeSeconds: 5, // if no result after 5s, start another call in parallel
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Launch all attempts simultaneously
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
const result = await racePromises({
|
|
71
|
+
generatePromise: () => callMyLLM(prompt),
|
|
72
|
+
amount: 3,
|
|
73
|
+
waitTimeSeconds: 0, // fire all 3 at once, take whoever responds first
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Single attempt, no retries
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
const result = await racePromises({
|
|
81
|
+
generatePromise: () => callMyLLM(prompt),
|
|
82
|
+
amount: 1,
|
|
83
|
+
waitTimeSeconds: 10, // waitTimeSeconds is irrelevant with amount: 1
|
|
84
|
+
});
|
|
85
|
+
// equivalent to just: await callMyLLM(prompt)
|
|
86
|
+
// but with consistent error handling via AggregateError on failure
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### With a custom retry condition
|
|
90
|
+
|
|
91
|
+
Stop retrying on errors you know are unrecoverable, like auth failures:
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
const result = await racePromises({
|
|
95
|
+
generatePromise: () => callMyLLM(prompt),
|
|
96
|
+
amount: 5,
|
|
97
|
+
waitTimeSeconds: 10,
|
|
98
|
+
shouldRetry: (e) => {
|
|
99
|
+
if (e?.status === 401 || e?.status === 403) return false;
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Handling background rejections
|
|
106
|
+
|
|
107
|
+
Once `racePromises` returns a winner, other in-flight promises keep running. If they later reject, the runtime will surface unhandled rejection warnings. Use `onBackgroundError` to handle them:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
const result = await racePromises({
|
|
111
|
+
generatePromise: () => callMyLLM(prompt),
|
|
112
|
+
amount: 3,
|
|
113
|
+
waitTimeSeconds: 5,
|
|
114
|
+
onBackgroundError: (e) => {
|
|
115
|
+
console.warn('A losing attempt failed after the winner was found:', e);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Handling all failures
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import racePromises, { RetryAbortedError } from './racePromises';
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const result = await racePromises({
|
|
127
|
+
generatePromise: () => callMyLLM(prompt),
|
|
128
|
+
amount: 3,
|
|
129
|
+
waitTimeSeconds: 5,
|
|
130
|
+
});
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (e instanceof RetryAbortedError) {
|
|
133
|
+
// shouldRetry returned false — e.cause is the error that triggered it
|
|
134
|
+
console.error('Gave up retrying due to:', e.cause);
|
|
135
|
+
} else if (e instanceof AggregateError) {
|
|
136
|
+
// every attempt failed — e.errors contains each individual rejection reason
|
|
137
|
+
console.error('All attempts failed:', e.errors);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Important notes
|
|
145
|
+
|
|
146
|
+
### `shouldRetry` is called per rejection, not per round
|
|
147
|
+
If multiple attempts are in-flight and they all fail at the same time, `shouldRetry` is called once for each rejection. If you implement a failure budget inside `shouldRetry` (e.g. "give up after 3 failures"), it will drain once per individual rejection — not once per stagger interval. Account for this when setting your budget.
|
|
148
|
+
|
|
149
|
+
### If `shouldRetry` itself throws, its exception becomes `RetryAbortedError.cause`
|
|
150
|
+
If your `shouldRetry` function throws an exception, that exception — not the original rejection that triggered `shouldRetry` — is used as the `cause` on the `RetryAbortedError`. This is intentional: the thrown exception is treated as the meaningful signal. If you see an unexpected `.cause` while debugging, check whether `shouldRetry` may be throwing internally.
|
|
151
|
+
|
|
152
|
+
### Promises are never cancelled
|
|
153
|
+
`waitTimeSeconds` means "if I haven't heard back in X seconds, start another one alongside it" — not "cancel and retry". A slow call is never stopped. If you need cleanup (e.g. aborting in-flight HTTP requests), pass an `AbortSignal` into your `generatePromise` and abort it once you have a result.
|
|
154
|
+
|
|
155
|
+
### `onBackgroundError` fires for losing rejections only
|
|
156
|
+
If a slow promise eventually *succeeds* after the function has already returned a winner, it is silently ignored — `onBackgroundError` is not called. It only fires for promises that eventually *fail* after the winner is known.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare class RetryAbortedError extends Error {
|
|
2
|
+
readonly cause: any;
|
|
3
|
+
constructor(cause: any);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Runs an async task with staggered retries and parallel racing.
|
|
7
|
+
*
|
|
8
|
+
* Starts the first attempt immediately. If it doesn't resolve within
|
|
9
|
+
* `waitTimeSeconds`, a second attempt is started in parallel — both remain
|
|
10
|
+
* in-flight and the first to succeed wins. This repeats up to `amount` total
|
|
11
|
+
* attempts. If an attempt fails, `shouldRetry` decides whether to keep going.
|
|
12
|
+
*
|
|
13
|
+
* This is useful for long-running or unreliable tasks (e.g. LLM calls) where
|
|
14
|
+
* you want low latency on success and automatic recovery from failures, without
|
|
15
|
+
* cancelling slow-but-potentially-valid in-flight requests.
|
|
16
|
+
*
|
|
17
|
+
* Throws `RetryAbortedError` if `shouldRetry` returns false.
|
|
18
|
+
* Throws `AggregateError` if all attempts fail without triggering an abort.
|
|
19
|
+
* The `AggregateError.errors` array contains each individual rejection reason.
|
|
20
|
+
*/
|
|
21
|
+
export default function racePromises<T>(params: {
|
|
22
|
+
generatePromise: () => Promise<T>;
|
|
23
|
+
amount: number;
|
|
24
|
+
waitTimeSeconds: number;
|
|
25
|
+
shouldRetry?: (e: any) => boolean;
|
|
26
|
+
onBackgroundError?: (e: any) => void;
|
|
27
|
+
}): Promise<T>;
|
|
28
|
+
export declare const sleepAsync: (ms: number) => Promise<void>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
export class RetryAbortedError extends Error {
|
|
2
|
+
constructor(cause) {
|
|
3
|
+
super('racePromises: shouldRetry() returned false. No further attempts will be made.');
|
|
4
|
+
this.name = 'RetryAbortedError';
|
|
5
|
+
this.cause = cause;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Runs an async task with staggered retries and parallel racing.
|
|
10
|
+
*
|
|
11
|
+
* Starts the first attempt immediately. If it doesn't resolve within
|
|
12
|
+
* `waitTimeSeconds`, a second attempt is started in parallel — both remain
|
|
13
|
+
* in-flight and the first to succeed wins. This repeats up to `amount` total
|
|
14
|
+
* attempts. If an attempt fails, `shouldRetry` decides whether to keep going.
|
|
15
|
+
*
|
|
16
|
+
* This is useful for long-running or unreliable tasks (e.g. LLM calls) where
|
|
17
|
+
* you want low latency on success and automatic recovery from failures, without
|
|
18
|
+
* cancelling slow-but-potentially-valid in-flight requests.
|
|
19
|
+
*
|
|
20
|
+
* Throws `RetryAbortedError` if `shouldRetry` returns false.
|
|
21
|
+
* Throws `AggregateError` if all attempts fail without triggering an abort.
|
|
22
|
+
* The `AggregateError.errors` array contains each individual rejection reason.
|
|
23
|
+
*/
|
|
24
|
+
export default async function racePromises(params) {
|
|
25
|
+
const { generatePromise, amount, waitTimeSeconds, onBackgroundError } = params;
|
|
26
|
+
const shouldRetry = params.shouldRetry ?? (() => true);
|
|
27
|
+
if (amount <= 0)
|
|
28
|
+
throw new Error('amount must be greater than 0');
|
|
29
|
+
if (waitTimeSeconds < 0)
|
|
30
|
+
throw new Error('waitTimeSeconds must be non-negative'); // 0 is valid: launch all simultaneously
|
|
31
|
+
const promises = [];
|
|
32
|
+
// One-shot semaphore: notifyUpdate() wakes the next waitForUpdate() call.
|
|
33
|
+
// pendingUpdate handles the case where notifyUpdate() fires before waitForUpdate()
|
|
34
|
+
// has been called — without it, that wakeup would be lost and the loop would sleep
|
|
35
|
+
// until the deadline unnecessarily.
|
|
36
|
+
let pendingUpdate = false;
|
|
37
|
+
let resolveUpdate = null;
|
|
38
|
+
function notifyUpdate() {
|
|
39
|
+
if (resolveUpdate) {
|
|
40
|
+
resolveUpdate();
|
|
41
|
+
resolveUpdate = null;
|
|
42
|
+
}
|
|
43
|
+
else
|
|
44
|
+
pendingUpdate = true;
|
|
45
|
+
}
|
|
46
|
+
function waitForUpdate() {
|
|
47
|
+
if (pendingUpdate) {
|
|
48
|
+
pendingUpdate = false;
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
}
|
|
51
|
+
return new Promise(res => { resolveUpdate = res; });
|
|
52
|
+
}
|
|
53
|
+
let hasSucceeded = false;
|
|
54
|
+
let successValue;
|
|
55
|
+
let failureCount = 0;
|
|
56
|
+
let rejections = []; // collected for AggregateError if all attempts fail
|
|
57
|
+
let keepRetrying = true;
|
|
58
|
+
let abortCause;
|
|
59
|
+
let isSettled = false; // true once the function has returned a result
|
|
60
|
+
function handleError(e) {
|
|
61
|
+
// First non-retryable error wins; subsequent ones are intentionally ignored since
|
|
62
|
+
// we've already decided to stop — the first abort signal is what matters.
|
|
63
|
+
if (keepRetrying) {
|
|
64
|
+
try {
|
|
65
|
+
if (!shouldRetry(e)) {
|
|
66
|
+
keepRetrying = false;
|
|
67
|
+
abortCause = e;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (shouldRetryException) {
|
|
71
|
+
// shouldRetry itself threw — use that exception as the cause, not the original
|
|
72
|
+
// error, since the exception from shouldRetry is the meaningful signal here.
|
|
73
|
+
keepRetrying = false;
|
|
74
|
+
abortCause = shouldRetryException;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function trackPromise(p) {
|
|
79
|
+
p.then((value) => {
|
|
80
|
+
if (!hasSucceeded)
|
|
81
|
+
successValue = value; // only capture the first winner
|
|
82
|
+
hasSucceeded = true;
|
|
83
|
+
notifyUpdate();
|
|
84
|
+
}).catch((e) => {
|
|
85
|
+
failureCount++;
|
|
86
|
+
rejections.push(e);
|
|
87
|
+
if (isSettled) {
|
|
88
|
+
onBackgroundError?.(e);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
handleError(e);
|
|
92
|
+
notifyUpdate();
|
|
93
|
+
});
|
|
94
|
+
return p;
|
|
95
|
+
}
|
|
96
|
+
function launchAttempt() {
|
|
97
|
+
try {
|
|
98
|
+
promises.push(trackPromise(generatePromise()));
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
// generatePromise threw synchronously — route through trackPromise so that
|
|
102
|
+
// all counter increments, rejection collection, handleError, and notifyUpdate
|
|
103
|
+
// are handled consistently with the async failure path.
|
|
104
|
+
promises.push(trackPromise(Promise.reject(e)));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (let i = 0; i < amount; i++) {
|
|
108
|
+
launchAttempt();
|
|
109
|
+
const deadline = Date.now() + waitTimeSeconds * 1000;
|
|
110
|
+
// deadlineSleep is created once and reused across inner loop iterations.
|
|
111
|
+
// This is intentional: a resolved promise stays resolved, so once the deadline
|
|
112
|
+
// fires it will keep winning the race and the while condition will end the loop.
|
|
113
|
+
// When waitTimeSeconds is 0, deadline === Date.now() so this loop never executes —
|
|
114
|
+
// success/failure is caught by the post-loop checks below.
|
|
115
|
+
const deadlineSleep = sleepAsync(waitTimeSeconds * 1000);
|
|
116
|
+
while (Date.now() < deadline) {
|
|
117
|
+
await Promise.race([waitForUpdate(), deadlineSleep]);
|
|
118
|
+
if (hasSucceeded) {
|
|
119
|
+
isSettled = true;
|
|
120
|
+
return successValue;
|
|
121
|
+
}
|
|
122
|
+
if (!keepRetrying) {
|
|
123
|
+
isSettled = true;
|
|
124
|
+
throw new RetryAbortedError(abortCause);
|
|
125
|
+
}
|
|
126
|
+
if (failureCount === i + 1)
|
|
127
|
+
break; // all attempts so far failed — start the next one early
|
|
128
|
+
}
|
|
129
|
+
if (hasSucceeded) {
|
|
130
|
+
isSettled = true;
|
|
131
|
+
return successValue;
|
|
132
|
+
}
|
|
133
|
+
if (!keepRetrying) {
|
|
134
|
+
isSettled = true;
|
|
135
|
+
throw new RetryAbortedError(abortCause);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!keepRetrying) {
|
|
139
|
+
isSettled = true;
|
|
140
|
+
throw new RetryAbortedError(abortCause);
|
|
141
|
+
}
|
|
142
|
+
// If every promise has already failed, throw explicitly rather than letting Promise.any
|
|
143
|
+
// throw an AggregateError opaquely — keeps the error contract consistent and deliberate.
|
|
144
|
+
if (failureCount === promises.length) {
|
|
145
|
+
isSettled = true;
|
|
146
|
+
throw new AggregateError(rejections, 'racePromises: all attempts failed.');
|
|
147
|
+
}
|
|
148
|
+
// Some promises are still in-flight. We await rather than returning the promise directly
|
|
149
|
+
// so that isSettled = true is only set after a winner is known — ensuring any rejections
|
|
150
|
+
// that arrive after that point are correctly routed to onBackgroundError.
|
|
151
|
+
// If Promise.any rejects (all in-flight promises ultimately fail), apply our own error
|
|
152
|
+
// contract: RetryAbortedError if shouldRetry fired, otherwise our custom AggregateError.
|
|
153
|
+
// This also covers waitTimeSeconds=0, where all attempts launch synchronously before any
|
|
154
|
+
// .catch() handlers run, so the pre-loop explicit checks above are never reached.
|
|
155
|
+
try {
|
|
156
|
+
const winner = await Promise.any(promises);
|
|
157
|
+
isSettled = true;
|
|
158
|
+
return winner;
|
|
159
|
+
}
|
|
160
|
+
catch (_) {
|
|
161
|
+
isSettled = true;
|
|
162
|
+
if (!keepRetrying)
|
|
163
|
+
throw new RetryAbortedError(abortCause);
|
|
164
|
+
throw new AggregateError(rejections, 'racePromises: all attempts failed.');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// exported for convenience — consider moving to a shared utilities module if you use it elsewhere
|
|
168
|
+
export const sleepAsync = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pico-brief/race-promises",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Async task runner with staggered retries and parallel racing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/racePromises.js",
|
|
7
|
+
"types": "./dist/racePromises.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/racePromises.js",
|
|
11
|
+
"types": "./dist/racePromises.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"test": "node --test racePromises.test.ts",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"retry",
|
|
24
|
+
"race",
|
|
25
|
+
"promises",
|
|
26
|
+
"async",
|
|
27
|
+
"llm"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^25.2.3",
|
|
32
|
+
"typescript": "^5.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|