fix(protocol): distinguish AbortSignal cancellation from request timeout - #2617
Open
xudongWu2022 wants to merge 1 commit into
Open
Conversation
Aborting a request through `RequestOptions.signal` rejected with `SdkErrorCode.RequestTimeout` — the same code an elapsed `RequestOptions.timeout` produces — so callers could not tell a deliberate cancellation apart from a real timeout, and retry/backoff logic gated on `RequestTimeout` fired on every user cancellation. Add `SdkErrorCode.RequestAborted` and move all four non-`SdkError` wrap sites onto it together, so the reported code no longer depends on where the abort lands: - `Protocol.request()` in-flight abort (the `cancel()` fallback) - `Protocol.request()` pre-aborted signal check - `Client._serveFromCache()` warm-hit pre-abort guard - `Client.listen()` pre-abort guard The timeout path is untouched: the timeout handler constructs a typed `SdkError(RequestTimeout, 'Request timed out')` which reaches `cancel()` as an `SdkError` and passes through the wrap unmodified. An abort `reason` that is already an `SdkError` is likewise still rethrown verbatim with its own code. Fixes modelcontextprotocol#2165
🦋 Changeset detectedLatest commit: c3b7441 The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Aborting a request through
RequestOptions.signalrejected withSdkErrorCode.RequestTimeout— the same code an elapsedRequestOptions.timeoutproduces — so callers had no way to tell a deliberate cancellation apart from a real timeout.Fixes #2165
Motivation and Context
The
cancel()fallback inProtocol._requestWithSchemaViaCodec()wrapped any non-SdkErrorabort reason asSdkErrorCode.RequestTimeout, andonAbort = () => cancel(options?.signal?.reason)feedsAbortSignal.reasonstraight into it. Retry/backoff logic gated onRequestTimeouttherefore fired on every user cancellation:This adds
SdkErrorCode.RequestAbortedand moves all four non-SdkErrorwrap sites onto it together, per the scope note in the triage comment on #2165:Protocol.request()in-flight abort (cancel()fallback)packages/core-internal/src/shared/protocol.tsProtocol.request()pre-aborted signal checkpackages/core-internal/src/shared/protocol.tsClient._serveFromCache()warm-hit pre-abort guardpackages/client/src/client/client.tsClient.listen()pre-abort guardpackages/client/src/client/client.tsMoving them together is the point: leaving the cache and
listen()sites onRequestTimeoutwould make the same abort surface as two different codes depending on whether the cache happened to be warm.The timeout path is untouched.
timeoutHandlerconstructs a typedSdkError(RequestTimeout, 'Request timed out', { timeout }), which reachescancel()as anSdkErrorand passes through the wrap via theinstanceof SdkErrorbranch. An abortreasonthat is already anSdkErroris likewise still rethrown verbatim with its own code.Relationship to #2177
@sakthiveltofficial opened #2177 for this issue first and landed the enum member plus the
cancel()fallback — credit for the diagnosis and the first fix is theirs. That PR predates thecore-internalpackage split, so it now conflicts withmain, and it covers one of the four wrap sites. This PR rebases the same idea onto currentmainand completes it: the remaining three sites, the two behavior pins and the enum ABI pin that lock the old contract, the migration-guide entries, and a changeset. Happy to close this in favour of an updated #2177 if @sakthiveltofficial would rather carry it — no ownership claim here, the issue had just been idle for a while.How Has This Been Tested?
New coverage in
packages/core-internal/test/shared/abortReasonPassthrough.test.ts:abort(new DOMException('User cancelled', 'AbortError'))against atimeout: 60_000request →RequestAborted, explicitly asserted notRequestTimeout, reason text preservedabort()with no reason →RequestAbortedtimeout: 0control → stillRequestTimeoutUpdated pins that locked the old wrap-as-
RequestTimeoutcontract:core-internal/test/shared/abortReasonPassthrough.test.ts— non-SdkErrorreason pincore-internal/test/types/errorSurfacePins.test.ts—SdkErrorCodeenum-membership ABI pinclient/test/client/responseCache.test.ts— warm-hit pre-abortclient/test/client/listen.test.ts—listen()pre-abort (the ack-timeout test in the same file correctly stays onRequestTimeout)Run locally:
Three suites fail on my Windows box for environment reasons and fail identically on an unmodified
main(verified by stashing): the codemod symlink-cycle test (EPERM— Windows needs elevation to create symlinks), and twoscenarios/stdio.test.tsshutdown-escalation cases (no POSIX signal escalation on Windows).pnpm docs:checkalso can't run on Windows — typedoc rejects\path separators.Breaking Changes
Behavioral, and deliberately so — it is the fix. Code that reached the
catchon a user cancellation and compared againstSdkErrorCode.RequestTimeoutnow seesRequestAborted. Anything checkingsignal.abortedfirst, or comparing againstRequestTimeoutonly for genuine timeouts, is unaffected.RequestAbortedis a new enum member, so nothing existing can be reading it yet.Changeset is
minorforcore-internal,client, andserver(the enum is re-exported throughcore-internal/publiconto both package roots). Migration guide updated indocs/migration/upgrade-to-v2.md: theSdkErrorCodetable gains a row, and the two already-aborted-signal entries now nameRequestAborted.Types of changes
Checklist
Additional context
Two follow-ups, deliberately kept out of this PR to keep it reviewable — happy to take either on:
v1.xbackport. The triage comment notesv1.xcarries the same shape withMcpError/ErrorCode.RequestTimeoutatsrc/shared/protocol.ts:1185. I'd rather send that as a separate PR againstv1.xthan mix branches here.inputRequiredDriver.ts.packages/core-internal/src/shared/inputRequiredDriver.ts:163and:172wrap asignal.reasonwith the identical pattern. They are outside the four sites the triage comment scoped, so I left them alone rather than widen the diff — but if you consider them part of the same contract, say the word and I'll fold them in (they'd need their pins ininputRequiredDriver.test.tsupdated too).