diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index e23d011a2a..cbe3233051 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -311,9 +311,12 @@ function classifyNetworkError(error: unknown, context: ProbeClassifierContext): } return { kind: 'error', - error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, { - cause: error - }) + error: new SdkError( + SdkErrorCode.EraNegotiationFailed, + `Version negotiation probe failed: ${describeError(error)}`, + undefined, + { cause: error } + ) }; } diff --git a/packages/core-internal/src/errors/sdkErrors.ts b/packages/core-internal/src/errors/sdkErrors.ts index 0bc8f9a1ad..1763aa8655 100644 --- a/packages/core-internal/src/errors/sdkErrors.ts +++ b/packages/core-internal/src/errors/sdkErrors.ts @@ -117,6 +117,27 @@ export enum SdkErrorCode { * } * ``` */ +/** + * Peel a mistaken `{ cause }` off the `data` bag so it can be forwarded to + * `Error`'s options. Call sites historically passed `{ cause }` as the third + * argument because TypeScript accepts any `data`; without this peel the + * underlying error lands in `.data.cause` and never on `.cause`, which breaks + * pino / Sentry cause-chain walkers. + */ +function splitSdkErrorData(data: unknown): { data?: unknown; cause?: unknown } { + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + return { data }; + } + if (!('cause' in data)) { + return { data }; + } + const { cause, ...rest } = data as Record; + return { + cause, + data: Object.keys(rest).length > 0 ? rest : undefined + }; +} + export class SdkError extends Error { static { Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SdkError' }); @@ -144,12 +165,18 @@ export class SdkError extends Error { return brandedHasInstance(this, value); } - constructor( - public readonly code: SdkErrorCode, - message: string, - public readonly data?: unknown - ) { - super(message); + /** + * Optional structured payload (HTTP status fields, timeout ms, etc.). + * Distinct from {@link Error.cause} — pass causes via `options` or as a + * `{ cause }` key inside `data` (peeled into `.cause` for back-compat). + */ + public readonly data?: unknown; + + constructor(public readonly code: SdkErrorCode, message: string, data?: unknown, options?: ErrorOptions) { + const split = splitSdkErrorData(data); + const cause = options?.cause ?? split.cause; + super(message, cause !== undefined ? { ...options, cause } : options); + this.data = split.data; this.name = 'SdkError'; stampErrorBrands(this, new.target); } @@ -187,8 +214,8 @@ export class SdkHttpError extends SdkError { declare readonly data: SdkHttpErrorData; - constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData) { - super(code, message, data); + constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData, options?: ErrorOptions) { + super(code, message, data, options); this.name = 'SdkHttpError'; } diff --git a/packages/core-internal/test/errors/sdkError.cause.test.ts b/packages/core-internal/test/errors/sdkError.cause.test.ts new file mode 100644 index 0000000000..c8ae0f5a70 --- /dev/null +++ b/packages/core-internal/test/errors/sdkError.cause.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { SdkError, SdkErrorCode, SdkHttpError } from '../../src/index'; + +describe('SdkError cause forwarding', () => { + it('forwards ErrorOptions.cause onto Error.cause', () => { + const root = new TypeError('fetch failed'); + const error = new SdkError(SdkErrorCode.EraNegotiationFailed, 'probe failed', undefined, { cause: root }); + + expect(error.cause).toBe(root); + expect(error.data).toBeUndefined(); + }); + + it('peels a mistaken { cause } out of the data bag onto Error.cause', () => { + const root = new TypeError('fetch failed'); + Object.defineProperty(root, 'cause', { + value: new Error('getaddrinfo ENOTFOUND does-not-resolve.invalid'), + configurable: true + }); + + // Historical call shape: third arg is `data`, but sites passed `{ cause }`. + const error = new SdkError(SdkErrorCode.EraNegotiationFailed, 'Version negotiation probe failed: fetch failed', { + cause: root + }); + + expect(error.cause).toBe(root); + expect(error.data).toBeUndefined(); + expect((error.cause as Error).cause).toBeInstanceOf(Error); + expect(((error.cause as Error).cause as Error).message).toContain('ENOTFOUND'); + }); + + it('keeps sibling data fields when peeling cause', () => { + const root = new Error('boom'); + const error = new SdkError(SdkErrorCode.RequestTimeout, 'timed out', { timeout: 5_000, cause: root }); + + expect(error.cause).toBe(root); + expect(error.data).toEqual({ timeout: 5_000 }); + }); + + it('does not invent a cause for ordinary data bags', () => { + const error = new SdkError(SdkErrorCode.RequestTimeout, 'timed out', { timeout: 5_000 }); + + expect(error.cause).toBeUndefined(); + expect(error.data).toEqual({ timeout: 5_000 }); + }); + + it('preserves SdkHttpError status data while allowing an options cause', () => { + const root = new Error('socket hang up'); + const error = new SdkHttpError( + SdkErrorCode.ClientHttpFailedToOpenStream, + 'stream failed', + { status: 502, statusText: 'Bad Gateway' }, + { cause: root } + ); + + expect(error).toBeInstanceOf(SdkHttpError); + expect(error.status).toBe(502); + expect(error.cause).toBe(root); + }); +});