-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(client): suppress onerror when SSE disconnect will be handled by reconnect #1827
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
31beb5b
dc7d763
e64d9ca
7ce0a21
bb9eef8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/client': patch | ||
| --- | ||
|
|
||
| Suppress `onerror` when an SSE stream disconnects but reconnection will be scheduled. Previously `onerror` fired unconditionally on every stream disconnect, producing `"SSE stream disconnected: TypeError: terminated"` noise every few minutes on long-lived connections even though the transport recovered transparently. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -325,16 +325,23 @@ | |
| /** | ||
| * Schedule a reconnection attempt using server-provided retry interval or backoff | ||
| * | ||
| * @param lastEventId The ID of the last received event for resumability | ||
| * @param attemptCount Current reconnection attempt count for this specific stream | ||
| * @param lastError The error that triggered this reconnection attempt, surfaced if max retries is exceeded | ||
| */ | ||
| private _scheduleReconnection(options: StartSSEOptions, attemptCount = 0): void { | ||
| private _scheduleReconnection(options: StartSSEOptions, attemptCount = 0, lastError?: unknown): void { | ||
|
Check warning on line 332 in packages/client/src/client/streamableHttp.ts
|
||
| // Use provided options or default options | ||
| const maxRetries = this._reconnectionOptions.maxRetries; | ||
|
|
||
| // Check if we've exceeded maximum retry attempts | ||
| if (attemptCount >= maxRetries) { | ||
| this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`)); | ||
| const reason = lastError === undefined ? undefined : lastError instanceof Error ? lastError.message : String(lastError); | ||
| this.onerror?.( | ||
| new Error( | ||
| `Maximum reconnection attempts (${maxRetries}) exceeded` + (reason ? `: ${reason}` : '.'), | ||
| lastError === undefined ? undefined : { cause: lastError } | ||
| ) | ||
| ); | ||
|
Comment on lines
+338
to
+344
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 When lastError is an Error with an empty message (e.g. new Error("")), the triggering error is silently omitted from the "Maximum reconnection attempts exceeded" message due to a falsy check on reason. Change reason ? to reason !== undefined ? so that empty-string reasons still produce a colon suffix that signals a cause exists. Extended reasoning...What the bug is and how it manifests In _scheduleReconnection, the PR introduces logic to surface the triggering error in the max-retries message at lines 338-344. When lastError is new Error(""), reason evaluates to "" — an empty string, which is falsy in JavaScript. The ternary (reason ? ': ' + reason : '.') then falls through to the period branch, producing the exact same message as the no-error case: "Maximum reconnection attempts (N) exceeded." The error.cause property is still set correctly, but callers who only inspect error.message (the common pattern) receive no indication that a triggering error was present. The specific code path that triggers it The relevant lines are 338-344 in packages/client/src/client/streamableHttp.ts. The reason variable is computed as: lastError === undefined ? undefined : lastError instanceof Error ? lastError.message : String(lastError). For new Error(""), this yields "". The ternary check (reason ? ...) treats this identically to undefined. The catch block in _handleSseStream passes error directly to _scheduleReconnection as the third argument, so any network throw producing an empty-message Error would trigger the silent drop. Why existing code doesn't prevent it The truthiness check reason ? is designed to detect "no reason" (i.e., lastError === undefined), but conflates two distinct states: (1) reason === undefined meaning no error was provided, and (2) reason === "" meaning an error was provided but has an empty message. Both are falsy, so both produce the same output. A strict undefined check (reason !== undefined) would correctly distinguish them. What the impact would be Users who only read error.message — the common pattern in logging, error display, and monitoring — would see "Maximum reconnection attempts (N) exceeded." for an empty-message error, identical to the case where no error occurred at all. They lose all indication that a root-cause error exists and must inspect error.cause to find it. In practice, real network errors (AbortError, TypeError: terminated, etc.) all have non-empty messages, so the practical impact is near-zero. The cause is preserved for programmatic inspection. Step-by-step proof
How to fix it Replace the truthiness check with an explicit undefined check: (reason !== undefined ? ': ' + reason : '.'). This produces "Maximum reconnection attempts (0) exceeded: " for empty-message errors — the trailing colon at minimum signals to readers that a cause was present — while still appending "." only when no error was provided at all.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Empty-message Errors don't occur on the network paths feeding this, and |
||
| return; | ||
| } | ||
|
|
||
|
|
@@ -347,7 +354,7 @@ | |
| this._startOrAuthSse(options).catch(error => { | ||
| this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); | ||
| try { | ||
| this._scheduleReconnection(options, attemptCount + 1); | ||
| this._scheduleReconnection(options, attemptCount + 1, error); | ||
| } catch (scheduleError) { | ||
| this.onerror?.(scheduleError instanceof Error ? scheduleError : new Error(String(scheduleError))); | ||
| } | ||
|
|
@@ -447,9 +454,6 @@ | |
| ); | ||
| } | ||
| } catch (error) { | ||
| // Handle stream errors - likely a network disconnect | ||
| this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); | ||
|
|
||
| // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing | ||
| // Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID) | ||
| // BUT don't reconnect if we already received a response - the request is complete | ||
claude[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
@@ -464,11 +468,15 @@ | |
| onresumptiontoken, | ||
| replayMessageId | ||
| }, | ||
| 0 | ||
| 0, | ||
| error | ||
| ); | ||
| } catch (error) { | ||
| this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); | ||
| } | ||
| } else if (!this._abortController?.signal.aborted) { | ||
| // Stream disconnected and reconnection will not happen; surface the error | ||
| this.onerror?.(new Error(`SSE stream disconnected: ${error instanceof Error ? error.message : String(error)}`)); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 The JSDoc for
_scheduleReconnectioncontains a stale@param lastEventIdthat no longer matches any parameter — the actual first parameter isoptions: StartSSEOptions. This is a pre-existing inaccuracy, but this PR directly modified the same JSDoc block by adding@param lastErrorwithout removing or correcting the stale entry. IDE hover-text and future contributors will see incorrect parameter documentation for this method.Extended reasoning...
What the bug is and how it manifests
The JSDoc comment block for
_scheduleReconnectioncontains@param lastEventId The ID of the last received event for resumability, but the actual first parameter of the method isoptions: StartSSEOptions. There is nolastEventIdparameter in the current signature. IDEs will surface this stale tag as hover-text whenever a developer inspects a call site, and any generated documentation tooling that processes private methods will also show the incorrect name.The specific code path that triggers it
The method signature after this PR is:
The JSDoc still documents three params:
lastEventId(wrong),attemptCount(correct), and the newly addedlastError(correct). The first tag is a fossil from an earlier version of the method when the first argument may have been alastEventIdstring.Why existing code does not prevent it
TypeScript does not enforce that JSDoc
@paramnames match the actual parameter names forprivatemethods (and even for public ones, only some strict-doc linters catch this). There is nothing in the build or CI pipeline that would flag the mismatch.What the impact would be
Developers reading the code or hovering over a call to
_scheduleReconnectionin an IDE will seelastEventIdlisted as the first parameter, which no longer exists. This is misleading and increases cognitive overhead when tracing the reconnection logic — exactly the part of the codebase this PR is modifying. The impact is purely documentation; runtime behavior is unaffected.How to fix it
Remove the stale
@param lastEventId ...line and replace it with the correct tag:Step-by-step proof
_scheduleReconnectionacceptedoptions: StartSSEOptionsas its first parameter — the@param lastEventIdtag was already wrong.@param lastErrorto the same block (the diff shows+ * @param lastError ...) without touching or removing@param lastEventId.@paramtags —lastEventId,attemptCount,lastError— while the actual signature hasoptions,attemptCount,lastError.this._scheduleReconnection({...}, 0, error)in VSCode will seelastEventIdas the first parameter description, which is incorrect.