Skip to content

Commit 2a4c74f

Browse files
CopilotJavanPoirier
andcommitted
fix(sse): apply reconnect budget to browser-level (CONNECTING) retries
Co-authored-by: JavanPoirier <42590458+JavanPoirier@users.noreply.github.com>
1 parent 326d05f commit 2a4c74f

2 files changed

Lines changed: 65 additions & 18 deletions

File tree

packages/sse/src/sse.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ export type CreateSSEOptions<T> = SSEOptions & {
8383
* - `true`: reconnect with defaults (Infinity retries, 3000ms delay)
8484
* - object: custom `{ retries?, delay? }`
8585
*
86-
* Note: `EventSource` already reconnects natively for transient network
87-
* drops. This option handles cases where the browser gives up entirely.
86+
* The `retries` budget is shared across both browser-level retries
87+
* (readyState stays CONNECTING) and app-level reconnects (readyState →
88+
* CLOSED). Once the budget is exhausted the connection is fully torn down,
89+
* stopping any further browser-driven retry loops.
8890
*/
8991
reconnect?: boolean | SSEReconnectOptions;
9092
/**
@@ -227,6 +229,13 @@ export const createSSE = <T = string>(
227229
// ── Connection management ─────────────────────────────────────────────────
228230
let currentCleanup: VoidFunction | undefined;
229231

232+
/** Tears down the current source without scheduling a reconnect. */
233+
const teardown = () => {
234+
currentCleanup?.();
235+
currentCleanup = undefined;
236+
setSource(undefined);
237+
};
238+
230239
/** Open a fresh connection, resetting the retry counter. */
231240
const connect = (resolvedUrl: string) => {
232241
retriesLeft = reconnectConfig.retries ?? 0;
@@ -255,16 +264,26 @@ export const createSSE = <T = string>(
255264
setError(() => e);
256265
options.onError?.(e);
257266

258-
// Only app-level reconnect when the browser has given up (CLOSED).
259-
// When readyState is still CONNECTING the browser is handling retries.
267+
// When the browser has given up (CLOSED), perform app-level reconnects
268+
// against the configured budget.
269+
// When the browser is still retrying (CONNECTING) and a reconnect budget
270+
// is configured, count those attempts too so the config is always honoured
271+
// and the browser can never loop infinitely beyond the configured limit.
260272
if (es.readyState === SSEReadyState.CLOSED && retriesLeft > 0) {
261273
retriesLeft--;
262274
reconnectTimer = setTimeout(() => _open(resolvedUrl), reconnectConfig.delay ?? 3000);
263275
} else if (es.readyState === SSEReadyState.CLOSED) {
264276
// Retries exhausted — clean up fully to avoid memory/listener leaks.
265-
currentCleanup?.();
266-
currentCleanup = undefined;
267-
setSource(undefined);
277+
teardown();
278+
} else if (es.readyState === SSEReadyState.CONNECTING && options.reconnect) {
279+
// Browser is retrying. Consume the budget; when it's gone, abort so
280+
// we don't loop forever against the user's configured retry limit.
281+
if (retriesLeft > 0) {
282+
retriesLeft--;
283+
} else {
284+
teardown();
285+
setReadyState(SSEReadyState.CLOSED);
286+
}
268287
}
269288
};
270289

@@ -285,9 +304,7 @@ export const createSSE = <T = string>(
285304
const disconnect = () => {
286305
clearReconnectTimer();
287306
retriesLeft = 0;
288-
currentCleanup?.();
289-
currentCleanup = undefined;
290-
setSource(undefined);
307+
teardown();
291308
setReadyState(SSEReadyState.CLOSED);
292309
};
293310

@@ -314,10 +331,7 @@ export const createSSE = <T = string>(
314331
const resolvedUrl = url();
315332
if (resolvedUrl !== prevUrl) {
316333
prevUrl = resolvedUrl;
317-
untrack(() => {
318-
currentCleanup?.();
319-
currentCleanup = undefined;
320-
});
334+
untrack(() => teardown());
321335
connect(resolvedUrl);
322336
}
323337
});
@@ -326,8 +340,7 @@ export const createSSE = <T = string>(
326340
// ── Lifecycle cleanup ─────────────────────────────────────────────────────
327341
onCleanup(() => {
328342
clearReconnectTimer();
329-
currentCleanup?.();
330-
currentCleanup = undefined;
343+
teardown();
331344
});
332345

333346
return { source, data, error, readyState, close: disconnect, reconnect: manualReconnect };

packages/sse/test/index.test.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ describe("createSSE", () => {
144144
dispose();
145145
}));
146146

147-
it("does not app-reconnect on transient errors (browser handles those)", () =>
147+
it("does not open a new connection on transient errors (browser retries natively)", () =>
148148
createRoot(dispose => {
149149
const initialCount = SSEInstances.length;
150150
const { source } = createSSE("https://example.com/events", {
@@ -153,7 +153,41 @@ describe("createSSE", () => {
153153
vi.advanceTimersByTime(20);
154154
(source() as unknown as MockEventSource).simulateTransientError();
155155
vi.advanceTimersByTime(300);
156-
// readyState stayed CONNECTING → no new EventSource was created
156+
// readyState stayed CONNECTING → no new EventSource was created, but
157+
// the retry budget was decremented by 1 (from 5 to 4).
158+
expect(SSEInstances.length).toBe(initialCount + 1);
159+
dispose();
160+
}));
161+
162+
it("stops browser retry loop when reconnect budget is exhausted via transient errors", () =>
163+
createRoot(dispose => {
164+
const { source, readyState } = createSSE("https://example.com/events", {
165+
reconnect: { retries: 2, delay: 50 },
166+
});
167+
vi.advanceTimersByTime(20);
168+
const es = source() as unknown as MockEventSource;
169+
const closeSpy = vi.spyOn(es, "close");
170+
// Two transient errors consume the full budget (2→1→0).
171+
es.simulateTransientError(); // retries: 2→1
172+
es.simulateTransientError(); // retries: 1→0
173+
// A third transient error exhausts the budget → connection must be stopped.
174+
es.simulateTransientError();
175+
expect(closeSpy).toHaveBeenCalledOnce();
176+
expect(source()).toBeUndefined();
177+
expect(readyState()).toBe(SSEReadyState.CLOSED);
178+
dispose();
179+
}));
180+
181+
it("does not affect transient errors when reconnect is not configured", () =>
182+
createRoot(dispose => {
183+
const initialCount = SSEInstances.length;
184+
const { source } = createSSE("https://example.com/events");
185+
vi.advanceTimersByTime(20);
186+
const es = source() as unknown as MockEventSource;
187+
// Transient errors with no reconnect config should not kill the connection.
188+
es.simulateTransientError();
189+
es.simulateTransientError();
190+
expect(source()).toBe(es);
157191
expect(SSEInstances.length).toBe(initialCount + 1);
158192
dispose();
159193
}));

0 commit comments

Comments
 (0)