Skip to content

Commit 1d296bd

Browse files
committed
review: count long-lived idle streams as progress, keep resumption token, dedupe scheduling
- A stream that stays open at least maxReconnectionDelay resets the attempt count even without messages, so healthy-but-quiet sessions whose standby stream is periodically idle-closed (e.g. behind an ALB) keep reconnecting indefinitely; only rapid connect-then-close cycles are bounded by maxRetries. - Hoist a single scheduleNext() closure used by both the graceful-close and mid-stream-error branches (the duplicated block is how the original bug happened). - Fall back to the stream's own resumptionToken when it ended before any event arrived, so empty reconnects no longer drop Last-Event-ID. - Update the migration guide bullet that claimed exhaustion accounting was unchanged from v1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Eqc26ABfbhTimyUUszsUxL
1 parent b1f8298 commit 1d296bd

4 files changed

Lines changed: 140 additions & 40 deletions

File tree

.changeset/bound-standby-sse-reconnects.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
'@modelcontextprotocol/client': patch
33
---
44

5-
Bound standby SSE reconnects when the server keeps idle-closing the stream. `StreamableHTTPClientTransport` reset its reconnection attempt count to `0` every time a stream ended, so a server that gracefully idle-closes the standby GET/SSE stream (spec-compliant behavior) kept the client reconnecting forever at `initialReconnectionDelay``maxRetries` never tripped, and every cycle re-ran the authenticated fetch path.
5+
Bound standby SSE reconnects when the server rapidly idle-closes the stream. `StreamableHTTPClientTransport` reset its reconnection attempt count to `0` every time a stream ended, so a server that gracefully idle-closes the standby GET/SSE stream immediately after every reconnect (spec-compliant behavior) kept the client reconnecting forever at `initialReconnectionDelay``maxRetries` never tripped, and every cycle re-ran the authenticated fetch path.
66

7-
The attempt count now persists across connect-then-close cycles that deliver no messages: after `maxRetries` consecutive fruitless reconnects the transport stops and surfaces `onerror` ("Maximum reconnection attempts exceeded"), exactly as it already did for reconnects that fail outright. A stream that delivers a message still resets the count, so healthy long-lived streams reconnect indefinitely as before.
7+
The attempt count now persists across connect-then-close cycles that make no progress: after `maxRetries` consecutive fruitless reconnects the transport stops and surfaces `onerror` ("Maximum reconnection attempts exceeded"), exactly as it already did for reconnects that fail outright. A stream counts as having made progress — and resets the count — when it delivers a message or stays open for at least `maxReconnectionDelay`, so healthy sessions whose idle standby stream is periodically closed by the server or an intermediary keep reconnecting indefinitely as before.
8+
9+
Also fixed in the same path: a reconnected stream that ended before any event arrived no longer drops the `Last-Event-ID` resumption token it was opened with — the next attempt resumes from the same token instead of silently starting a fresh stream.

docs/migration/upgrade-to-v2.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1511,11 +1511,18 @@ rewrite required unless noted.
15111511
no `notifications/cancelled` goes on the wire — the spec forbids cancelling
15121512
`initialize`, and v1 sent one anyway. v1 tests asserting that notification need
15131513
re-baselining.
1514-
- **Also unchanged: SSE reconnection exhaustion.** `StreamableHTTPClientTransport`'s
1515-
standalone GET-stream reconnection behavior and its exhaustion signal carry over from
1516-
v1: when retries run out, the transport emits `onerror` with a plain `Error` whose
1517-
message is `Maximum reconnection attempts (N) exceeded.` — there is no typed error
1518-
class for this condition, so monitors that match the message text keep working.
1514+
- **Changed: SSE reconnection exhaustion accounting.** The exhaustion _signal_ carries
1515+
over from v1: when retries run out, `StreamableHTTPClientTransport` emits `onerror`
1516+
with a plain `Error` whose message is `Maximum reconnection attempts (N) exceeded.`
1517+
there is no typed error class for this condition, so monitors that match the message
1518+
text keep working. The _accounting_ changed: v1 reset the retry counter every time a
1519+
stream closed, so a server that gracefully idle-closed the standby GET stream
1520+
immediately after each reconnect kept the client reconnecting forever and
1521+
`maxRetries` never fired. v2 counts consecutive reconnects that make no progress —
1522+
no message delivered and the connection lasted less than `maxReconnectionDelay` — so
1523+
such loops now stop with the error above after `maxRetries` attempts. Streams that
1524+
deliver a message or stay open at least `maxReconnectionDelay` reset the counter and
1525+
reconnect indefinitely, exactly as in v1.
15191526
- **Also unchanged: elicitation response validation.** `elicitInput`'s local validation
15201527
of elicitation responses against `requestedSchema`, the resulting `-32602` error
15211528
message wording (`Elicitation response content does not match requested schema: …`),

packages/client/src/client/streamableHttp.ts

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,14 @@ export interface StreamableHTTPReconnectionOptions {
118118
/**
119119
* Maximum number of consecutive reconnection attempts before giving up.
120120
* An attempt counts against this limit when it fails outright or when the
121-
* reconnected stream ends again without having delivered a message (for
122-
* example, a server that gracefully idle-closes its standby SSE stream);
123-
* a stream that delivers a message resets the count.
121+
* reconnected stream ends again without making progress — no message
122+
* delivered and the connection lasted less than
123+
* {@linkcode maxReconnectionDelay} (for example, a server that gracefully
124+
* idle-closes its standby SSE stream immediately after every reconnect).
125+
* A stream that delivers a message or stays open at least
126+
* {@linkcode maxReconnectionDelay} resets the count, so healthy sessions
127+
* whose standby stream is periodically idle-closed keep reconnecting
128+
* indefinitely.
124129
* Default is 2.
125130
*/
126131
maxRetries: number;
@@ -717,9 +722,10 @@ export class StreamableHTTPClientTransport implements Transport {
717722
/**
718723
* @param reconnectAttempt - Which reconnection attempt produced this
719724
* stream (0 for an original stream). Carried into the next
720-
* `_scheduleReconnection` call when the stream ends without having
721-
* delivered a message, so consecutive fruitless reconnects are bounded by
722-
* `maxRetries`; a stream that delivered a message resets the count.
725+
* `_scheduleReconnection` call when the stream ends without having made
726+
* progress (no message delivered, connection shorter than
727+
* `maxReconnectionDelay`), so consecutive fruitless reconnects are bounded
728+
* by `maxRetries`; a stream that made progress resets the count.
723729
*/
724730
private _handleSseStream(
725731
stream: ReadableStream<Uint8Array> | null,
@@ -756,6 +762,40 @@ export class StreamableHTTPClientTransport implements Transport {
756762
// keeping repeated connect/idle-close cycles bounded by `maxRetries`
757763
// (#2682).
758764
let receivedMessage = false;
765+
const streamOpenedAt = Date.now();
766+
767+
// Single scheduling site for both the graceful-close and the
768+
// mid-stream-error paths below — the #2682 bug existed precisely
769+
// because the two branches carried separate copies of this block.
770+
const scheduleNext = (): void => {
771+
// A stream counts as having made progress when it delivered a
772+
// message, or when it stayed open for at least
773+
// `maxReconnectionDelay` before ending — an idle standby stream
774+
// that a server (or intermediary) periodically closes is healthy,
775+
// and resetting the count for it can never produce a reconnect
776+
// rate faster than the maximum backoff already permits. Without
777+
// progress, a connect-then-close cycle is retry-equivalent to a
778+
// failed attempt: the count continues so a server that idle-closes
779+
// every standby stream right away cannot keep the transport
780+
// looping forever (#2682). Priming events alone deliberately do
781+
// not reset the count — a server can send one and still close
782+
// immediately, which would re-arm exactly that loop.
783+
const madeProgress = receivedMessage || Date.now() - streamOpenedAt >= this._reconnectionOptions.maxReconnectionDelay;
784+
this._scheduleReconnection(
785+
{
786+
// A reconnected stream that ended before any event arrived
787+
// must not drop the token the stream was opened with —
788+
// fall back to it so the next attempt still resumes.
789+
resumptionToken: lastEventId ?? options.resumptionToken,
790+
onresumptiontoken,
791+
replayMessageId,
792+
requestSignal,
793+
onRequestStreamEnd
794+
},
795+
madeProgress ? 0 : reconnectAttempt
796+
);
797+
};
798+
759799
const processStream = async () => {
760800
// this is the closest we can get to trying to catch network errors
761801
// if something happens reader will throw
@@ -818,21 +858,7 @@ export class StreamableHTTPClientTransport implements Transport {
818858
const canResume = isReconnectable || hasPrimingEvent;
819859
const needsReconnect = canResume && !receivedResponse;
820860
if (needsReconnect && this._abortController && !isIntentionalAbort()) {
821-
// A stream that delivered a message was genuinely working —
822-
// restart the attempt count. A graceful close without one is
823-
// retry-equivalent to a failed attempt: continue the count so
824-
// a server that idle-closes every standby stream cannot keep
825-
// the transport reconnecting forever (#2682).
826-
this._scheduleReconnection(
827-
{
828-
resumptionToken: lastEventId,
829-
onresumptiontoken,
830-
replayMessageId,
831-
requestSignal,
832-
onRequestStreamEnd
833-
},
834-
receivedMessage ? 0 : reconnectAttempt
835-
);
861+
scheduleNext();
836862
} else if (!isIntentionalAbort()) {
837863
// The per-request stream ended without reconnecting (no
838864
// priming event for a POST stream, or response already
@@ -855,19 +881,9 @@ export class StreamableHTTPClientTransport implements Transport {
855881
const needsReconnect = canResume && !receivedResponse;
856882
if (needsReconnect && this._abortController && !isIntentionalAbort()) {
857883
// Use the exponential backoff reconnection strategy. Same
858-
// accounting as the graceful-close path: only a stream that
859-
// delivered a message restarts the attempt count.
884+
// accounting as the graceful-close path.
860885
try {
861-
this._scheduleReconnection(
862-
{
863-
resumptionToken: lastEventId,
864-
onresumptiontoken,
865-
replayMessageId,
866-
requestSignal,
867-
onRequestStreamEnd
868-
},
869-
receivedMessage ? 0 : reconnectAttempt
870-
);
886+
scheduleNext();
871887
} catch (error) {
872888
this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`));
873889
onRequestStreamEnd?.();

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2652,6 +2652,81 @@ describe('StreamableHTTPClientTransport', () => {
26522652
await vi.advanceTimersByTimeAsync(5000);
26532653
expect(fetchMock).toHaveBeenCalledTimes(4);
26542654
});
2655+
2656+
it('treats a long-lived idle stream as progress, so periodic idle-closes reconnect indefinitely', async () => {
2657+
// A healthy-but-quiet session behind e.g. a load balancer with an
2658+
// idle timeout: the standby stream delivers nothing but stays open
2659+
// well past maxReconnectionDelay before each close. That must NOT
2660+
// count against maxRetries — the notification channel stays alive.
2661+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
2662+
reconnectionOptions: {
2663+
initialReconnectionDelay: 10,
2664+
maxReconnectionDelay: 1000,
2665+
reconnectionDelayGrowFactor: 1,
2666+
maxRetries: 2
2667+
}
2668+
});
2669+
const errorSpy = vi.fn();
2670+
transport.onerror = errorSpy;
2671+
2672+
const controllers: ReadableStreamDefaultController<Uint8Array>[] = [];
2673+
const fetchMock = globalThis.fetch as Mock;
2674+
fetchMock.mockImplementation(async () => ({
2675+
ok: true,
2676+
status: 200,
2677+
headers: new Headers({ 'content-type': 'text/event-stream' }),
2678+
body: new ReadableStream<Uint8Array>({
2679+
start(controller) {
2680+
controllers.push(controller);
2681+
}
2682+
})
2683+
}));
2684+
2685+
await transport.start();
2686+
await transport['_startOrAuthSse']({});
2687+
2688+
// Five cycles: each stream lives longer than maxReconnectionDelay
2689+
// (fake timers also drive Date.now), then idle-closes empty.
2690+
for (let cycle = 0; cycle < 5; cycle++) {
2691+
await vi.advanceTimersByTimeAsync(1500);
2692+
controllers[cycle]!.close();
2693+
await vi.advanceTimersByTimeAsync(50);
2694+
}
2695+
2696+
// Well past 1 + maxRetries fetches, and no exhaustion error.
2697+
expect(fetchMock.mock.calls.length).toBeGreaterThan(3);
2698+
expect(errorSpy).not.toHaveBeenCalled();
2699+
});
2700+
2701+
it('carries the resumption token through reconnected streams that ended before any event', async () => {
2702+
// Stream 1 delivers a priming event (id only), so the first
2703+
// reconnect resumes from it. Stream 2 ends empty — the next
2704+
// attempt must still send the same Last-Event-ID instead of
2705+
// dropping it and starting a fresh stream.
2706+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
2707+
reconnectionOptions: {
2708+
initialReconnectionDelay: 10,
2709+
maxReconnectionDelay: 1000,
2710+
reconnectionDelayGrowFactor: 1,
2711+
maxRetries: 3
2712+
}
2713+
});
2714+
2715+
const fetchMock = globalThis.fetch as Mock;
2716+
const bodies: string[][] = [['id: event-1\ndata: \n\n']];
2717+
fetchMock.mockImplementation(async () => sseResponse(bodies.shift() ?? []));
2718+
2719+
await transport.start();
2720+
await transport['_startOrAuthSse']({});
2721+
await vi.advanceTimersByTimeAsync(100);
2722+
2723+
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(3);
2724+
const secondCallHeaders = fetchMock.mock.calls[1]![1]?.headers as Headers;
2725+
const thirdCallHeaders = fetchMock.mock.calls[2]![1]?.headers as Headers;
2726+
expect(secondCallHeaders.get('last-event-id')).toBe('event-1');
2727+
// Before the fix this was null: the empty second stream dropped the token.
2728+
expect(thirdCallHeaders.get('last-event-id')).toBe('event-1');
2729+
});
26552730
});
26562731

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

0 commit comments

Comments
 (0)