Skip to content

AbortSignal cancellation throws SdkErrorCode.RequestTimeout instead of a distinct abort error #2165

Open

Description

@sakthiveltofficial

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.ts

Expected 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

  1. sakthiveltofficial commented on May 28, 2026

    @sakthiveltofficial
    Author

    I'd like to fix this — could you assign this issue to me?

  2. norika1207-lab commented on Jun 2, 2026

    @norika1207-lab
  3. added
    triageQueued for automated analysis — bot will process and remove this label
    on Jul 9, 2026
  4. ghost added
    good first issueGood for newcomers - can be tackled without deep knowledge of the codebase
    on Jul 9, 2026
  5. ghost added
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    on Jul 9, 2026
  6. ghost added
    fix proposedBot has a verified fix diff in the comment
    on Jul 9, 2026
  7. ghost removed
    triageQueued for automated analysis — bot will process and remove this label
    on Jul 9, 2026
  8. xudongWu2022 commented on Aug 6, 2026

    @xudongWu2022

    Opened #2617 for this.

    @sakthiveltofficial got here first with #2177 — the diagnosis and the enum + cancel() fix are theirs. That PR predates the core-internal package split so it now conflicts with main, 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 current main and completed it rather than leave the fix half-applied:

    • all four wrap sites moved together (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 warm
    • the three pins that lock the old contract: the SdkErrorCode enum-membership ABI pin, plus the behavior pins in abortReasonPassthrough.test.ts, responseCache.test.ts and listen.test.ts
    • new coverage for in-flight DOMException('AbortError') and bare abort(), and a timeout: 0 control asserting the timeout path still reports RequestTimeout
    • migration-guide entries and a changeset

    @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.x backport (would rather send separately against that branch), and whether inputRequiredDriver.ts:163/:172 should move too — they use the identical wrap but sit outside the four-site scope, so I left them out pending a maintainer call.

  9. Parithosh-Varma commented on Sep 23, 2026

    @Parithosh-Varma

    Implemented on branch https://github.com/Parithosh-Varma/typescript-sdk/tree/fix/abort-error-code-2165 (couldn't open the PR — same fork CreatePullRequest denial as #2661).

    Adds SdkErrorCode.RequestAborted + a shared toRequestError() helper used by both the pre-aborted and in-flight cancel() paths; branded SdkError reasons pass through, everything else stays RequestTimeout. One deliberate deviation from the suggested snippet: the final else stays RequestTimeout so 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.

  10. YingzuoLiu commented on Oct 25, 2026

    @YingzuoLiu

    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.signal documents an AbortError, while request() currently wraps non-SdkError abort reasons in SdkError(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 during initialize yields an SdkError with code === RequestTimeout, distinct from the supplied reason; during notifications/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:

    • Reject with the original abort reason, including non-Error reasons.
    • Retain wrapping and preserve the original reason in Error.cause. This improves diagnostics, but keeping RequestTimeout still leaves callers that branch only on code unable to distinguish cancellation from timeout.
    • Introduce a dedicated cancellation error code, as proposed above, with an explicit policy for retaining the original reason.

    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 update records the current cross-stage abort boundary; update alongside request() cancellation semantics in #3079's connectHandshakeTimeout.test.ts alongside that change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingfix proposedBot has a verified fix diff in the commentgood first issueGood for newcomers - can be tackled without deep knowledge of the codebase

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions