Skip to content

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

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

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