@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 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
+ }