@@ -99,6 +99,9 @@ export interface StartSSEOptions {
9999export 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 }
0 commit comments