Skip to content

Commit abcf521

Browse files
committed
review: settle exhaustion via try/finally at depth, track all pending reconnections
- _scheduleReconnection now guarantees the caller settles on every no-reconnection-pending exit: exhaustion wraps onerror in try/finally, and a throwing custom scheduler settles before rethrowing. The caller-side catches in _handleSseStream become containment-only. - Replace the single _cancelReconnection slot with a Set so close() cancels every parked timer across concurrent chains (standby GET plus resumed per-request streams), not just the latest. - Document maxReconnectionDelay's second role as the stream-lifetime progress threshold. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Eqc26ABfbhTimyUUszsUxL
1 parent f83507f commit abcf521

2 files changed

Lines changed: 96 additions & 20 deletions

File tree

packages/client/src/client/streamableHttp.ts

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ export interface StartSSEOptions {
9999
export interface StreamableHTTPReconnectionOptions {
100100
/**
101101
* Maximum backoff time between reconnection attempts in milliseconds.
102+
* Also serves as the stream-lifetime threshold for retry accounting: a
103+
* stream that stays open at least this long counts as progress and resets
104+
* the {@linkcode maxRetries} attempt count (see {@linkcode maxRetries}).
102105
* Default is 30000 (30 seconds).
103106
*/
104107
maxReconnectionDelay: number;
@@ -335,7 +338,7 @@ export class StreamableHTTPClientTransport implements Transport {
335338
private _maxStepUpRetries: number;
336339
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
337340
private readonly _reconnectionScheduler?: ReconnectionScheduler;
338-
private _cancelReconnection?: () => void;
341+
private _pendingReconnections = new Set<() => void>();
339342

340343
onclose?: () => void;
341344
onerror?: (error: Error) => void;
@@ -678,17 +681,25 @@ export class StreamableHTTPClientTransport implements Transport {
678681

679682
// Check if we've exceeded maximum retry attempts
680683
if (attemptCount >= maxRetries) {
681-
this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`));
682-
// The per-request stream is now definitively gone.
683-
options.onRequestStreamEnd?.();
684+
try {
685+
this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`));
686+
} finally {
687+
// The per-request stream is now definitively gone. Settlement
688+
// must survive a throwing user `onerror` handler on every
689+
// route into this branch (graceful close, mid-stream error,
690+
// failed reconnect chain) — callers only contain propagation,
691+
// they never settle.
692+
options.onRequestStreamEnd?.();
693+
}
684694
return;
685695
}
686696

687697
// Calculate next delay based on current attempt count
688698
const delay = this._getNextReconnectionDelay(attemptCount);
689699

700+
let cancelEntry: () => void;
690701
const reconnect = (): void => {
691-
this._cancelReconnection = undefined;
702+
this._pendingReconnections.delete(cancelEntry);
692703
// Honour BOTH the transport-wide abort and the per-request abort
693704
// (a listen subscription closed during the backoff delay): do not
694705
// resurrect a stream the caller already tore down.
@@ -710,13 +721,32 @@ export class StreamableHTTPClientTransport implements Transport {
710721
});
711722
};
712723

713-
if (this._reconnectionScheduler) {
714-
const cancel = this._reconnectionScheduler(reconnect, delay, attemptCount);
715-
this._cancelReconnection = typeof cancel === 'function' ? cancel : undefined;
716-
} else {
717-
const handle = setTimeout(reconnect, delay);
718-
this._cancelReconnection = () => clearTimeout(handle);
724+
try {
725+
if (this._reconnectionScheduler) {
726+
const cancel = this._reconnectionScheduler(reconnect, delay, attemptCount);
727+
cancelEntry =
728+
typeof cancel === 'function'
729+
? cancel
730+
: () => {
731+
// No-op: the custom scheduler provided no cancel
732+
// function; tracked so `reconnect` can still
733+
// deregister the chain's pending entry.
734+
};
735+
} else {
736+
const handle = setTimeout(reconnect, delay);
737+
cancelEntry = () => clearTimeout(handle);
738+
}
739+
} catch (error) {
740+
// A throwing custom scheduler means no reconnection is pending —
741+
// the stream is definitively gone. Settle the caller here (the
742+
// only route that can still do it), then rethrow for reporting.
743+
options.onRequestStreamEnd?.();
744+
throw error;
719745
}
746+
// Track every pending reconnection — concurrent chains (the standby
747+
// GET stream plus resumed per-request streams) each park a timer
748+
// here, and close() must cancel all of them, not just the latest.
749+
this._pendingReconnections.add(cancelEntry);
720750
}
721751

722752
/**
@@ -864,12 +894,13 @@ export class StreamableHTTPClientTransport implements Transport {
864894
// `ReconnectionScheduler` — is reachable from a graceful
865895
// close, and a throwing user callback must not fall into
866896
// the outer catch (which would surface a misleading
867-
// disconnect error and schedule a second time).
897+
// disconnect error and schedule a second time). Containment
898+
// only: `_scheduleReconnection` itself guarantees the
899+
// caller settles on every no-reconnection-pending exit.
868900
try {
869901
scheduleNext();
870902
} catch (error) {
871903
this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`));
872-
onRequestStreamEnd?.();
873904
}
874905
} else if (!isIntentionalAbort()) {
875906
// The per-request stream ended without reconnecting (no
@@ -893,12 +924,12 @@ export class StreamableHTTPClientTransport implements Transport {
893924
const needsReconnect = canResume && !receivedResponse;
894925
if (needsReconnect && this._abortController && !isIntentionalAbort()) {
895926
// Use the exponential backoff reconnection strategy. Same
896-
// accounting as the graceful-close path.
927+
// accounting as the graceful-close path; containment only,
928+
// settlement is guaranteed inside `_scheduleReconnection`.
897929
try {
898930
scheduleNext();
899931
} catch (error) {
900932
this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`));
901-
onRequestStreamEnd?.();
902933
}
903934
} else {
904935
// Non-deliberate stream error without reconnection: the
@@ -974,9 +1005,13 @@ export class StreamableHTTPClientTransport implements Transport {
9741005

9751006
async close(): Promise<void> {
9761007
try {
977-
this._cancelReconnection?.();
1008+
// Cancel EVERY pending reconnection — concurrent chains (standby
1009+
// GET + resumed per-request streams) can each have a parked timer.
1010+
for (const cancel of this._pendingReconnections) {
1011+
cancel();
1012+
}
9781013
} finally {
979-
this._cancelReconnection = undefined;
1014+
this._pendingReconnections.clear();
9801015
this._abortController?.abort();
9811016
this.onclose?.();
9821017
}

packages/client/test/client/streamableHttp.test.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2497,7 +2497,7 @@ describe('StreamableHTTPClientTransport', () => {
24972497
);
24982498

24992499
// Verify no reconnection was scheduled
2500-
expect(transport['_cancelReconnection']).toBeUndefined();
2500+
expect(transport['_pendingReconnections'].size).toBe(0);
25012501
});
25022502

25032503
it('should schedule reconnection when maxRetries is greater than 0', async () => {
@@ -2519,10 +2519,13 @@ describe('StreamableHTTPClientTransport', () => {
25192519

25202520
// ASSERT - should schedule a reconnection, not report error yet
25212521
expect(errorSpy).not.toHaveBeenCalled();
2522-
expect(transport['_cancelReconnection']).toBeDefined();
2522+
expect(transport['_pendingReconnections'].size).toBe(1);
25232523

25242524
// Clean up the pending reconnection to avoid test pollution
2525-
transport['_cancelReconnection']?.();
2525+
for (const cancel of transport['_pendingReconnections']) {
2526+
cancel();
2527+
}
2528+
transport['_pendingReconnections'].clear();
25262529
});
25272530
});
25282531

@@ -2851,6 +2854,44 @@ describe('StreamableHTTPClientTransport', () => {
28512854
expect(streamEndSpy).toHaveBeenCalledTimes(1);
28522855
expect(errorSpy).toHaveBeenCalled();
28532856
});
2857+
2858+
it('close() cancels every pending reconnection timer, not just the latest', async () => {
2859+
// Two concurrent reconnect chains (standby GET + a resumed
2860+
// per-request stream) each park a timer; close() must cancel both.
2861+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
2862+
reconnectionOptions: {
2863+
initialReconnectionDelay: 5000,
2864+
maxReconnectionDelay: 30000,
2865+
reconnectionDelayGrowFactor: 1,
2866+
maxRetries: 3
2867+
}
2868+
});
2869+
const fetchMock = globalThis.fetch as Mock;
2870+
// Every stream closes empty immediately, so each chain schedules
2871+
// a reconnection 5s out.
2872+
fetchMock.mockImplementation(async () => sseResponse(['id: keep-1\ndata: \n\n']));
2873+
2874+
const requestMessage: JSONRPCRequest = {
2875+
jsonrpc: '2.0',
2876+
method: 'long_running_tool',
2877+
id: 'request-1',
2878+
params: {}
2879+
};
2880+
2881+
await transport.start();
2882+
await transport['_startOrAuthSse']({});
2883+
await transport.send(requestMessage, { resumptionToken: 'event-0' });
2884+
await vi.advanceTimersByTimeAsync(10);
2885+
2886+
expect(transport['_pendingReconnections'].size).toBe(2);
2887+
expect(vi.getTimerCount()).toBe(2);
2888+
2889+
await transport.close();
2890+
2891+
// Both parked timers were cancelled, not just the latest.
2892+
expect(transport['_pendingReconnections'].size).toBe(0);
2893+
expect(vi.getTimerCount()).toBe(0);
2894+
});
28542895
});
28552896

28562897
describe('prevent infinite recursion when server returns 401 after successful auth', () => {

0 commit comments

Comments
 (0)