v2 backwards compat: SdkError status code (#2049)

This commit is contained in:
Konstantin Konstantinov 2026-05-21 08:09:32 +03:00 • committed by GitHub
commit 4f226c1e35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 137 additions and 45 deletions

View file

@ -1,5 +1,12 @@
import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
import { createFetchWithInit, JSONRPCMessageSchema, normalizeHeaders, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
import {
createFetchWithInit,
JSONRPCMessageSchema,
normalizeHeaders,
SdkError,
SdkErrorCode,
SdkHttpError
} from '@modelcontextprotocol/core';
import type { ErrorEvent, EventSourceInit } from 'eventsource';
import { EventSource } from 'eventsource';
@ -286,8 +293,9 @@ export class SSEClientTransport implements Transport {
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401,
statusText: response.statusText
});
}
throw new UnauthorizedError();

View file

@ -10,7 +10,8 @@ import {
JSONRPCMessageSchema,
normalizeHeaders,
SdkError,
SdkErrorCode
SdkErrorCode,
SdkHttpError
} from '@modelcontextprotocol/core';
import { EventSourceParserStream } from 'eventsource-parser/stream';
@ -273,8 +274,9 @@ export class StreamableHTTPClientTransport implements Transport {
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401,
statusText: response.statusText
});
}
throw new UnauthorizedError();
@ -288,7 +290,7 @@ export class StreamableHTTPClientTransport implements Transport {
return;
}
throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, {
throw new SdkHttpError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, {
status: response.status,
statusText: response.statusText
});
@ -581,8 +583,9 @@ export class StreamableHTTPClientTransport implements Transport {
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401,
statusText: response.statusText
});
}
throw new UnauthorizedError();
@ -598,8 +601,9 @@ export class StreamableHTTPClientTransport implements Transport {
// Check if we've already tried upscoping with this header to prevent infinite loops.
if (this._lastUpscopingHeader === wwwAuthHeader) {
throw new SdkError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', {
throw new SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', {
status: 403,
statusText: response.statusText,
text
});
}
@ -629,8 +633,9 @@ export class StreamableHTTPClientTransport implements Transport {
}
}
throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, {
throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, {
status: response.status,
statusText: response.statusText,
text
});
}
@ -725,10 +730,14 @@ export class StreamableHTTPClientTransport implements Transport {
// We specifically handle 405 as a valid response according to the spec,
// meaning the server does not support explicit session termination
if (!response.ok && response.status !== 405) {
throw new SdkError(SdkErrorCode.ClientHttpFailedToTerminateSession, `Failed to terminate session: ${response.statusText}`, {
status: response.status,
statusText: response.statusText
});
throw new SdkHttpError(
SdkErrorCode.ClientHttpFailedToTerminateSession,
`Failed to terminate session: ${response.statusText}`,
{
status: response.status,
statusText: response.statusText
}
);
}
this._sessionId = undefined;

View file

@ -3,7 +3,7 @@ import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { JSONRPCMessage, OAuthTokens } from '@modelcontextprotocol/core';
import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core';
import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers';
import type { Mock, Mocked, MockedFunction, MockInstance } from 'vitest';
@ -1575,7 +1575,7 @@ describe('SSEClientTransport', () => {
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
});
it('enforces circuit breaker on double-401: onUnauthorized called once, then throws SdkError', async () => {
it('enforces circuit breaker on double-401: onUnauthorized called once, then throws SdkHttpError', async () => {
postResponses = [401, 401];
await setupServer();
@ -1587,8 +1587,9 @@ describe('SSEClientTransport', () => {
await transport.start();
const error = await transport.send(message).catch(e => e);
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
expect(error).toBeInstanceOf(SdkHttpError);
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
expect((error as SdkHttpError).status).toBe(401);
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1);
expect(postCount).toBe(2);
});

View file

@ -1,5 +1,5 @@
import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core';
import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core';
import type { Mock, Mocked } from 'vitest';
import type { OAuthClientProvider } from '../../src/client/auth.js';
@ -240,8 +240,9 @@ describe('StreamableHTTPClientTransport', () => {
transport.onerror = errorSpy;
await expect(transport.send(message)).rejects.toThrow(
new SdkError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', {
new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', {
status: 404,
statusText: 'Not Found',
text: 'Session not found'
})
);
@ -1871,8 +1872,9 @@ describe('StreamableHTTPClientTransport', () => {
.mockResolvedValueOnce(unauthedResponse);
const error = await transport.send(message).catch(e => e);
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
expect(error).toBeInstanceOf(SdkHttpError);
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
expect((error as SdkHttpError).status).toBe(401);
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
access_token: 'new-access-token',
token_type: 'Bearer',

View file

@ -2,7 +2,7 @@ import type { IncomingMessage, Server } from 'node:http';
import { createServer } from 'node:http';
import type { JSONRPCMessage, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core';
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
import { SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core';
import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers';
import type { Mock } from 'vitest';
@ -86,7 +86,7 @@ describe('StreamableHTTPClientTransport with AuthProvider', () => {
expect(retryInit.headers.get('Authorization')).toBe('Bearer new-token');
});
it('should throw SdkError(ClientHttpAuthentication) if retry after onUnauthorized also gets 401', async () => {
it('should throw SdkHttpError(ClientHttpAuthentication) if retry after onUnauthorized also gets 401', async () => {
const authProvider: AuthProvider = {
token: vi.fn(async () => 'still-bad'),
onUnauthorized: vi.fn(async () => {})
@ -99,8 +99,9 @@ describe('StreamableHTTPClientTransport with AuthProvider', () => {
.mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), text: async () => 'unauthorized' });
const error = await transport.send(message).catch(e => e);
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
expect(error).toBeInstanceOf(SdkHttpError);
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
expect((error as SdkHttpError).status).toBe(401);
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1);
});

View file

@ -7,7 +7,7 @@
* @module
*/
import { SdkError, SdkErrorCode } from './sdkErrors.js';
import { SdkError, SdkErrorCode, SdkHttpError } from './sdkErrors.js';
/**
* Example: Throwing and catching SDK errors.
@ -25,3 +25,15 @@ function SdkError_basicUsage() {
}
//#endregion SdkError_basicUsage
}
/**
* Example: Checking for HTTP transport errors.
*/
function SdkHttpError_basicUsage(error: unknown) {
//#region SdkHttpError_basicUsage
if (error instanceof SdkHttpError) {
console.log(error.status); // number
console.log(error.statusText); // string | undefined
}
//#endregion SdkHttpError_basicUsage
}

View file

@ -66,3 +66,45 @@ export class SdkError extends Error {
this.name = 'SdkError';
}
}
/**
* Typed shape for HTTP error data carried by {@linkcode SdkHttpError}.
*/
export interface SdkHttpErrorData {
status: number;
statusText?: string;
[key: string]: unknown;
}
/**
* An {@linkcode SdkError} subclass for HTTP transport failures.
*
* Thrown by the streamable HTTP transport when the server responds with a
* non-OK status code. Narrows {@linkcode SdkError.data | data} to
* {@linkcode SdkHttpErrorData} so consumers can inspect the HTTP status
* without unsafe casting.
*
* @example
* ```ts source="./sdkErrors.examples.ts#SdkHttpError_basicUsage"
* if (error instanceof SdkHttpError) {
* console.log(error.status); // number
* console.log(error.statusText); // string | undefined
* }
* ```
*/
export class SdkHttpError extends SdkError {
declare readonly data: SdkHttpErrorData;
constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData) {
super(code, message, data);
this.name = 'SdkHttpError';
}
get status(): number {
return this.data.status;
}
get statusText(): string | undefined {
return this.data.statusText;
}
}

View file

@ -13,7 +13,8 @@
export { OAuthError, OAuthErrorCode } from '../../auth/errors.js';
// SDK error types (local errors that never cross the wire)
export { SdkError, SdkErrorCode } from '../../errors/sdkErrors.js';
export type { SdkHttpErrorData } from '../../errors/sdkErrors.js';
export { SdkError, SdkErrorCode, SdkHttpError } from '../../errors/sdkErrors.js';
// Auth TypeScript types (NOT Zod schemas like OAuthMetadataSchema)
export type {