Repository navigation
AbortSignal cancellation throws SdkErrorCode.RequestTimeout instead of a distinct abort error #2165
Description
Describe the bug
When you cancel a request using AbortSignal, the error you get back has code: 'REQUEST_TIMEOUT' — the same code as an actual timeout. There's no way to tell them apart.
I had logic like this in my app:
if (error.code === SdkErrorCode.RequestTimeout) {
showRetryMessage(); // was also firing on manual user cancellation
}Both a real timeout and a deliberate controller.abort() hit the same fallback in cancel() inside protocol.ts (line ~910), which unconditionally wraps any non-SdkError reason as SdkErrorCode.RequestTimeout.
To Reproduce
import { Client } from '@modelcontextprotocol/client';
import { Server } from '@modelcontextprotocol/server';
import { InMemoryTransport, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = new Server({ name: 'test-server', version: '1.0' });
const client = new Client({ name: 'test-client', version: '1.0' });
// handler that hangs until cancelled
server.setRequestHandler('ping', async (_req, ctx) => {
await new Promise<void>((_, reject) => {
ctx.mcpReq.signal.addEventListener('abort', () => reject(new Error('cancelled')));
});
return {};
});
await server.connect(serverTransport);
await client.connect(clientTransport);
const controller = new AbortController();
// abort at 30ms — timeout is 60s, so it is definitely not the timeout firing
setTimeout(() => controller.abort(new DOMException('User cancelled', 'AbortError')), 30);
try {
await client.request({ method: 'ping' }, { signal: controller.signal, timeout: 60_000 });
} catch (error) {
if (error instanceof SdkError) {
console.log(error.code); // REQUEST_TIMEOUT ← should be something like REQUEST_ABORTED
}
}Or run the repro test directly:
pnpm --filter "@modelcontextprotocol/test-integration" test -- test/bug-repros/bug1-abort-signal.test.tsExpected behavior
Aborting via AbortSignal should produce a distinct error code (e.g. REQUEST_ABORTED) so callers can tell it apart from a real timeout. The timeout path already explicitly passes a typed SdkError — only the abort path falls through to the generic RequestTimeout fallback.
Logs
Caught error code: "REQUEST_TIMEOUT"
Caught error msg : "AbortError: User cancelled the request"
FAIL test/bug-repros/bug1-abort-signal.test.ts
AssertionError:
expected 'REQUEST_TIMEOUT' not to be 'REQUEST_TIMEOUT'
Additional context
Root cause is in packages/core/src/shared/protocol.ts around line 910:
const cancel = (reason: unknown) => {
const error = reason instanceof SdkError
? reason
: new SdkError(SdkErrorCode.RequestTimeout, String(reason)); // ← always RequestTimeout
reject(error);
};Quick fix would be adding RequestAborted = 'REQUEST_ABORTED' to SdkErrorCode and checking the reason type in cancel():
let error: SdkError;
if (reason instanceof SdkError) {
error = reason;
} else if (reason instanceof DOMException && reason.name === 'AbortError') {
error = new SdkError(SdkErrorCode.RequestAborted, reason.message);
} else {
error = new SdkError(SdkErrorCode.RequestAborted, String(reason));
}
reject(error);
Activity
sakthiveltofficial commentedon May 28, 2026
I'd like to fix this — could you assign this issue to me?
norika1207-lab commentedon Jun 2, 2026
xudongWu2022 commentedon Aug 6, 2026
Opened #2617 for this.
@sakthiveltofficial got here first with #2177 — the diagnosis and the enum +
cancel()fix are theirs. That PR predates thecore-internalpackage split so it now conflicts withmain, and it covers one of the four wrap sites the triage comment above flags. Since it had been idle a while I rebased the same idea onto currentmainand completed it rather than leave the fix half-applied:request()in-flight abort,request()pre-abort check,Client._serveFromCache()warm-hit pre-abort,Client.listen()pre-abort) — otherwise the same abort reports a different code depending on whether the cache was warmSdkErrorCodeenum-membership ABI pin, plus the behavior pins inabortReasonPassthrough.test.ts,responseCache.test.tsandlisten.test.tsDOMException('AbortError')and bareabort(), and atimeout: 0control asserting the timeout path still reportsRequestTimeout@sakthiveltofficial — no ownership claim intended, happy to close mine if you'd rather bring #2177 up to date.
Two things left open in the PR description: the
v1.xbackport (would rather send separately against that branch), and whetherinputRequiredDriver.ts:163/:172should move too — they use the identical wrap but sit outside the four-site scope, so I left them out pending a maintainer call.Parithosh-Varma commentedon Sep 23, 2026
Implemented on branch https://github.com/Parithosh-Varma/typescript-sdk/tree/fix/abort-error-code-2165 (couldn't open the PR — same fork
CreatePullRequestdenial as #2661).Adds
SdkErrorCode.RequestAborted+ a sharedtoRequestError()helper used by both the pre-aborted and in-flightcancel()paths; brandedSdkErrorreasons pass through, everything else staysRequestTimeout. One deliberate deviation from the suggested snippet: the finalelsestaysRequestTimeoutso genuinely unknown errors aren't mislabeled as aborts. Includes changeset, error-surface pin update, and 2 new tests (full core-internal suite: 1459 passed, tsgo clean). Happy to open the PR if the gate lifts.YingzuoLiu commentedon Oct 25, 2026
Adding a documentation and compatibility question from #3079 to this existing discussion rather than opening a duplicate. I see #2177 and #2617 are already linked above; this is additional context, not a proposed replacement fix.
RequestOptions.signaldocuments anAbortError, whilerequest()currently wraps non-SdkErrorabort reasons inSdkError(RequestTimeout, ...). Before changing the behavior, could maintainers confirm the intended cancellation contract and how the documentation should describe it?There is a concrete cross-stage reproduction in the boundary test at 753157bd: the same
Error('user cancel')is used for two legacy handshake stages. Aborting duringinitializeyields anSdkErrorwithcode === RequestTimeout, distinct from the supplied reason; duringnotifications/initialized, the PR rejects with the original reason object. The test uses a fake transport and records the current boundary; it does not establish that this asymmetry should be permanent.Options worth discussing:
Error.cause. This improves diagnostics, but keepingRequestTimeoutstill leaves callers that branch only oncodeunable to distinguish cancellation from timeout.Changing the error type or code is observable to callers, including those already relying on
code === RequestTimeout. Under the repository's backwards-compatibility guidance, an implementation should document the compatibility impact in its changeset and consider the existing pre-abort, in-flight, cache, and listen paths together. This comment does not assume which option maintainers should choose.If
request()cancellation semantics change, please updaterecords the current cross-stage abort boundary; update alongside request() cancellation semanticsin #3079'sconnectHandshakeTimeout.test.tsalongside that change.