Skip to content

Commit 4835860

Browse files
alb-rlcursoragent
andauthored
SSE auto-reconnect for axons; AbortSignal for poll-based waits (#765)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: albert <alb-rl@users.noreply.github.com>
1 parent af4d2dc commit 4835860

17 files changed

Lines changed: 1095 additions & 115 deletions

src/core.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ export type { _Array as Array, _Record as Record };
5151

5252
type PromiseOrValue<T> = T | Promise<T>;
5353

54-
type APIResponseProps = {
54+
/** @internal Response metadata paired with each HTTP request; used by streaming helpers. */
55+
export type APIResponseProps = {
5556
response: Response;
5657
options: FinalRequestOptions;
5758
controller: AbortController;
@@ -130,6 +131,11 @@ export class APIPromise<T> extends Promise<T> {
130131
);
131132
}
132133

134+
/** @internal Same promise backing {@link asResponse}; includes the full request context. */
135+
_getResponseProps(): Promise<APIResponseProps> {
136+
return this.responsePromise;
137+
}
138+
133139
/**
134140
* Gets the raw `Response` instance instead of parsing the response
135141
* data.
@@ -189,6 +195,16 @@ export class APIPromise<T> extends Promise<T> {
189195
}
190196
}
191197

198+
/**
199+
* @internal Promise for a {@link Stream} built after the HTTP response is available
200+
* (e.g. SSE with reconnect). Preserves {@link APIPromise} helpers like {@link APIPromise.asResponse}.
201+
*/
202+
export class StreamBackedAPIPromise<T> extends APIPromise<T> {
203+
constructor(responseProps: Promise<APIResponseProps>, getData: () => Promise<T>) {
204+
super(responseProps, () => getData());
205+
}
206+
}
207+
192208
export abstract class APIClient {
193209
baseURL: string;
194210
#baseURLOverridden: boolean;

src/lib/polling.ts

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export interface PollingOptions<T> {
1010
maxAttempts?: number;
1111
/** Optional timeout for the entire polling operation (in milliseconds) */
1212
timeoutMs?: number;
13+
/** Optional AbortSignal to cancel polling and in-flight delays between attempts. */
14+
signal?: AbortSignal | null | undefined;
1315
/**
1416
* Condition to check if polling should stop
1517
* Return true when the condition is met and polling should stop
@@ -196,9 +198,29 @@ export class LongPollAbortError extends Error {
196198
}
197199

198200
/**
199-
* Delay execution for specified milliseconds
201+
* Delay execution for specified milliseconds, aborting with {@link LongPollAbortError} when `signal` is aborted.
200202
*/
201-
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
203+
function abortableDelay(
204+
ms: number,
205+
signal: AbortSignal | null | undefined,
206+
lastResult: unknown,
207+
): Promise<void> {
208+
return new Promise((resolve, reject) => {
209+
if (signal?.aborted) {
210+
reject(new LongPollAbortError('Polling aborted', lastResult));
211+
return;
212+
}
213+
const timer = setTimeout(() => {
214+
signal?.removeEventListener('abort', onAbort);
215+
resolve();
216+
}, ms);
217+
const onAbort = () => {
218+
clearTimeout(timer);
219+
reject(new LongPollAbortError('Polling aborted', lastResult));
220+
};
221+
signal?.addEventListener('abort', onAbort, { once: true });
222+
});
223+
}
202224

203225
/**
204226
* Generic polling function that handles polling logic with configurable options
@@ -212,11 +234,23 @@ export async function poll<T>(
212234
pollingRequest: () => Promise<T>,
213235
options: PollingOptions<T> = {},
214236
): Promise<T> {
215-
const { initialDelayMs, pollingIntervalMs, maxAttempts, timeoutMs, shouldStop, onPollingAttempt, onError } =
216-
{
217-
...DEFAULT_OPTIONS,
218-
...options,
219-
};
237+
const {
238+
initialDelayMs,
239+
pollingIntervalMs,
240+
maxAttempts,
241+
timeoutMs,
242+
shouldStop,
243+
onPollingAttempt,
244+
onError,
245+
signal,
246+
} = {
247+
...DEFAULT_OPTIONS,
248+
...options,
249+
};
250+
251+
if (signal?.aborted) {
252+
throw new LongPollAbortError('Polling aborted', undefined);
253+
}
220254

221255
if (initialDelayMs !== undefined && initialDelayMs < 0) {
222256
throw new Error('initialDelayMs must be non-negative');
@@ -253,10 +287,35 @@ export async function poll<T>(
253287
const raceTimeout = <R>(promise: Promise<R>): Promise<R> =>
254288
timeoutPromise ? Promise.race([promise, timeoutPromise]) : promise;
255289

290+
const raceAbort = <R>(promise: Promise<R>): Promise<R> => {
291+
if (!signal) return promise;
292+
const abortSignal = signal;
293+
if (abortSignal.aborted) {
294+
return Promise.reject(new LongPollAbortError('Polling aborted', lastResult));
295+
}
296+
return new Promise((resolve, reject) => {
297+
const onAbort = () => {
298+
abortSignal.removeEventListener('abort', onAbort);
299+
reject(new LongPollAbortError('Polling aborted', lastResult));
300+
};
301+
abortSignal.addEventListener('abort', onAbort);
302+
promise.then(
303+
(value) => {
304+
abortSignal.removeEventListener('abort', onAbort);
305+
resolve(value);
306+
},
307+
(err) => {
308+
abortSignal.removeEventListener('abort', onAbort);
309+
reject(err);
310+
},
311+
);
312+
});
313+
};
314+
256315
try {
257316
let result: T;
258317
try {
259-
result = await raceTimeout(initialRequest());
318+
result = await raceAbort(raceTimeout(initialRequest()));
260319
} catch (error) {
261320
if (onError && error instanceof APIError) {
262321
result = onError(error);
@@ -275,7 +334,7 @@ export async function poll<T>(
275334
return result;
276335
}
277336

278-
await raceTimeout(delay(initialDelayMs!));
337+
await raceAbort(raceTimeout(abortableDelay(initialDelayMs!, signal, lastResult)));
279338

280339
let attempts = 0;
281340

@@ -284,7 +343,7 @@ export async function poll<T>(
284343
++attempts;
285344

286345
try {
287-
result = await raceTimeout(pollingRequest());
346+
result = await raceAbort(raceTimeout(pollingRequest()));
288347
} catch (error) {
289348
if (onError && error instanceof APIError) {
290349
result = onError(error);
@@ -304,7 +363,7 @@ export async function poll<T>(
304363
throw new MaxAttemptsExceededError(`Polling exceeded maximum attempts (${maxAttempts})`, result);
305364
}
306365

307-
await raceTimeout(delay(pollingIntervalMs!));
366+
await raceAbort(raceTimeout(abortableDelay(pollingIntervalMs!, signal, lastResult)));
308367
}
309368

310369
// This should only be reachable if maxAttempts is defined

src/lib/streaming-reconnection.ts

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,58 @@
11
import { Stream } from '../streaming';
2+
import { APIPromise, StreamBackedAPIPromise, type APIResponseProps } from '../core';
23

34
/**
45
* Wraps a stream with automatic reconnection on timeout.
6+
* Returns an {@link APIPromise} so callers can use {@link APIPromise.asResponse} and
7+
* {@link APIPromise.withResponse} like other streaming endpoints.
58
*/
6-
export async function withStreamAutoReconnect<Item>(
7-
streamCreator: (offset: number | undefined) => Promise<Stream<Item>>,
9+
export function withStreamAutoReconnect<Item>(
10+
streamCreator: (offset: number | undefined) => APIPromise<Stream<Item>>,
811
getOffset: (item: Item) => number | undefined,
9-
): Promise<Stream<Item>> {
10-
let lastOffset: number | undefined = undefined;
11-
let currentStream = await streamCreator(lastOffset);
12+
): StreamBackedAPIPromise<Stream<Item>> {
13+
let firstRequest: APIPromise<Stream<Item>> | undefined;
14+
const ensureFirst = () => (firstRequest ??= streamCreator(undefined));
1215

13-
async function* createReconnectingIterator(): AsyncIterator<Item> {
14-
while (true) {
15-
try {
16-
for await (const item of currentStream) {
17-
if (getOffset(item) !== undefined) {
18-
lastOffset = getOffset(item);
16+
// Defer the first HTTP request until something awaits this promise (avoids eager
17+
// connection attempts and unhandled rejections when the caller only attaches later).
18+
const responsePropsPromise = new Promise<APIResponseProps>((resolve, reject) => {
19+
queueMicrotask(() => {
20+
ensureFirst()._getResponseProps().then(resolve, reject);
21+
});
22+
});
23+
24+
let dataPromiseMemo: Promise<Stream<Item>> | undefined;
25+
const getDataPromise = () => {
26+
if (!dataPromiseMemo) {
27+
dataPromiseMemo = (async () => {
28+
let lastOffset: number | undefined = undefined;
29+
let currentStream = await ensureFirst();
30+
31+
async function* createReconnectingIterator(): AsyncIterator<Item> {
32+
while (true) {
33+
try {
34+
for await (const item of currentStream) {
35+
if (getOffset(item) !== undefined) {
36+
lastOffset = getOffset(item);
37+
}
38+
yield item;
39+
}
40+
return; // Stream completed normally
41+
} catch (error) {
42+
if ((error as any)?.status === 408) {
43+
currentStream = await streamCreator(lastOffset);
44+
continue;
45+
}
46+
throw error; // Not a timeout, rethrow
47+
}
1948
}
20-
yield item;
2149
}
22-
return; // Stream completed normally
23-
} catch (error) {
24-
if ((error as any)?.status === 408) {
25-
// Reconnect with the last known offset
26-
currentStream = await streamCreator(lastOffset);
27-
continue;
28-
}
29-
throw error; // Not a timeout, rethrow
30-
}
50+
51+
return new Stream(createReconnectingIterator, currentStream.controller);
52+
})();
3153
}
32-
}
54+
return dataPromiseMemo;
55+
};
3356

34-
return new Stream(createReconnectingIterator, currentStream.controller);
57+
return new StreamBackedAPIPromise(responsePropsPromise, getDataPromise);
3558
}

src/resources/axons.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isRequestOptions } from '../core';
55
import { APIPromise } from '../core';
66
import * as Core from '../core';
77
import { Stream } from '../streaming';
8+
import { withStreamAutoReconnect } from '@runloop/api-client/lib/streaming-reconnection';
89

910
export class Axons extends APIResource {
1011
/**
@@ -49,19 +50,36 @@ export class Axons extends APIResource {
4950

5051
/**
5152
* [Beta] Subscribe to an axon event stream via server-sent events.
53+
* On idle timeout (408), reconnects with `after_sequence` derived from the last
54+
* received event (internal to {@link withStreamAutoReconnect}).
5255
*/
5356
subscribeSse(id: string, options?: Core.RequestOptions): APIPromise<Stream<AxonEventView>> {
54-
const defaultHeaders = {
55-
Accept: 'text/event-stream',
56-
};
5757
const mergedOptions: Core.RequestOptions = {
58-
headers: defaultHeaders,
5958
...options,
59+
headers: {
60+
Accept: 'text/event-stream',
61+
...options?.headers,
62+
},
63+
};
64+
const { query: userQuery, ...restMerged } = mergedOptions;
65+
const getStream: (afterSequence: number | undefined) => APIPromise<Stream<AxonEventView>> = (
66+
afterSequence,
67+
) => {
68+
const base =
69+
userQuery && typeof userQuery === 'object' && !Array.isArray(userQuery) ?
70+
{ ...(userQuery as Record<string, string | undefined>) }
71+
: {};
72+
const query =
73+
afterSequence !== undefined ? { ...base, after_sequence: afterSequence.toString() }
74+
: Object.keys(base).length > 0 ? base
75+
: undefined;
76+
return this._client.get(`/v1/axons/${id}/subscribe/sse`, {
77+
...restMerged,
78+
...(query ? { query } : {}),
79+
stream: true,
80+
}) as APIPromise<Stream<AxonEventView>>;
6081
};
61-
return this._client.get(`/v1/axons/${id}/subscribe/sse`, {
62-
...mergedOptions,
63-
stream: true,
64-
}) as APIPromise<Stream<AxonEventView>>;
82+
return withStreamAutoReconnect(getStream, (item) => item.sequence);
6583
}
6684
}
6785

src/resources/axons/axons.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from './sql';
2020
import { AxonsCursorIDPage, type AxonsCursorIDPageParams } from '../../pagination';
2121
import { Stream } from '../../streaming';
22+
import { withStreamAutoReconnect } from '@runloop/api-client/lib/streaming-reconnection';
2223

2324
export class Axons extends APIResource {
2425
sql: SqlAPI.Sql = new SqlAPI.Sql(this._client);
@@ -76,6 +77,8 @@ export class Axons extends APIResource {
7677

7778
/**
7879
* [Beta] Subscribe to an axon event stream via server-sent events.
80+
* On idle timeout (408), reconnects with `after_sequence` derived from the last
81+
* received event (internal to {@link withStreamAutoReconnect}).
7982
*/
8083
subscribeSse(id: string, options?: Core.RequestOptions): APIPromise<Stream<AxonEventView>> {
8184
const mergedOptions: Core.RequestOptions = {
@@ -85,10 +88,25 @@ export class Axons extends APIResource {
8588
...options?.headers,
8689
},
8790
};
88-
return this._client.get(`/v1/axons/${id}/subscribe/sse`, {
89-
...mergedOptions,
90-
stream: true,
91-
}) as APIPromise<Stream<AxonEventView>>;
91+
const { query: userQuery, ...restMerged } = mergedOptions;
92+
const getStream: (afterSequence: number | undefined) => APIPromise<Stream<AxonEventView>> = (
93+
afterSequence,
94+
) => {
95+
const base =
96+
userQuery && typeof userQuery === 'object' && !Array.isArray(userQuery) ?
97+
{ ...(userQuery as Record<string, string | undefined>) }
98+
: {};
99+
const query =
100+
afterSequence !== undefined ? { ...base, after_sequence: afterSequence.toString() }
101+
: Object.keys(base).length > 0 ? base
102+
: undefined;
103+
return this._client.get(`/v1/axons/${id}/subscribe/sse`, {
104+
...restMerged,
105+
...(query ? { query } : {}),
106+
stream: true,
107+
}) as APIPromise<Stream<AxonEventView>>;
108+
};
109+
return withStreamAutoReconnect(getStream, (item) => item.sequence);
92110
}
93111
}
94112

0 commit comments

Comments
 (0)