From 4461157c5ee81edd31bd4dde8c15a2a592261030 Mon Sep 17 00:00:00 2001 From: anshu Date: Fri, 14 Aug 2026 15:37:05 +0530 Subject: [PATCH] fix: forward Error.cause in SdkError constructor (#2657) SdkError's third parameter is `data`, not `ErrorOptions`, so `classifyNetworkError`'s `{ cause: error }` landed in `data` instead of `Error.cause`. This broke standard cause-chain walking used by loggers (pino) and error trackers (Sentry). Add an `options?: ErrorOptions` parameter to both `SdkError` and `SdkHttpError` constructors, forwarding it to `super()`. Fix the call site in `classifyNetworkError` to pass the underlying error as `options.cause` rather than as `data`. --- packages/client/src/client/probeClassifier.ts | 2 +- .../client/test/client/probeAuthSeam.test.ts | 2 +- .../core-internal/src/errors/sdkErrors.ts | 9 +-- .../test/errors/sdkErrorCause.test.ts | 61 +++++++++++++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 packages/core-internal/test/errors/sdkErrorCause.test.ts diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index e23d011a2a..5b8627607a 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -311,7 +311,7 @@ function classifyNetworkError(error: unknown, context: ProbeClassifierContext): } return { kind: 'error', - error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, { + error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, undefined, { cause: error }) }; diff --git a/packages/client/test/client/probeAuthSeam.test.ts b/packages/client/test/client/probeAuthSeam.test.ts index a347d71cd5..265928a48e 100644 --- a/packages/client/test/client/probeAuthSeam.test.ts +++ b/packages/client/test/client/probeAuthSeam.test.ts @@ -283,6 +283,6 @@ describe('stamped-seam fault injection (identity-preserving auth outcomes, never expect(out.settled).toBe('rejected'); expect(out.error).toBeInstanceOf(SdkError); expect((out.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); - expect(((out.error as SdkError).data as { cause?: unknown }).cause).toBe(netError); + expect((out.error as SdkError).cause).toBe(netError); }); }); diff --git a/packages/core-internal/src/errors/sdkErrors.ts b/packages/core-internal/src/errors/sdkErrors.ts index 0bc8f9a1ad..9e07d93cdf 100644 --- a/packages/core-internal/src/errors/sdkErrors.ts +++ b/packages/core-internal/src/errors/sdkErrors.ts @@ -147,9 +147,10 @@ export class SdkError extends Error { constructor( public readonly code: SdkErrorCode, message: string, - public readonly data?: unknown + public readonly data?: unknown, + options?: ErrorOptions ) { - super(message); + super(message, options); this.name = 'SdkError'; stampErrorBrands(this, new.target); } @@ -187,8 +188,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/sdkErrorCause.test.ts b/packages/core-internal/test/errors/sdkErrorCause.test.ts new file mode 100644 index 0000000000..458393d241 --- /dev/null +++ b/packages/core-internal/test/errors/sdkErrorCause.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { SdkError, SdkErrorCode, SdkHttpError } from '../../src/index'; + +describe('SdkError cause chain', () => { + it('forwards cause to Error.cause when passed via options', () => { + const root = new TypeError('ENOTFOUND'); + const error = new SdkError(SdkErrorCode.EraNegotiationFailed, 'probe failed', undefined, { + cause: root + }); + + expect(error.cause).toBe(root); + expect(error.data).toBeUndefined(); + }); + + it('keeps data and cause independent', () => { + const root = new Error('connection refused'); + const error = new SdkError( + SdkErrorCode.RequestTimeout, + 'timed out', + { timeout: 5000 }, + { + cause: root + } + ); + + expect(error.cause).toBe(root); + expect(error.data).toEqual({ timeout: 5000 }); + }); + + it('leaves cause undefined when options are omitted', () => { + const error = new SdkError(SdkErrorCode.NotConnected, 'not connected'); + expect(error.cause).toBeUndefined(); + }); + + it('leaves cause undefined when only data is passed', () => { + const error = new SdkError(SdkErrorCode.RequestTimeout, 'timed out', { timeout: 5000 }); + expect(error.cause).toBeUndefined(); + }); +}); + +describe('SdkHttpError cause chain', () => { + it('forwards cause through to Error.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.cause).toBe(root); + expect(error.status).toBe(502); + expect(error.data.status).toBe(502); + }); + + it('leaves cause undefined when options are omitted', () => { + const error = new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'auth failed', { status: 401 }); + + expect(error.cause).toBeUndefined(); + }); +});