diff --git a/.changeset/big-bikes-wash.md b/.changeset/big-bikes-wash.md new file mode 100644 index 000000000..cb824bf7d --- /dev/null +++ b/.changeset/big-bikes-wash.md @@ -0,0 +1,5 @@ +--- +'@tanstack/pacer': patch +--- + +Fix AsyncRetryer error normalization to preserve plain-object message values and attach the original thrown value as cause. diff --git a/packages/pacer/src/async-retryer.ts b/packages/pacer/src/async-retryer.ts index 4bbce86d2..8e1f7f443 100644 --- a/packages/pacer/src/async-retryer.ts +++ b/packages/pacer/src/async-retryer.ts @@ -538,7 +538,17 @@ export class AsyncRetryer { ) { return undefined } - lastError = error instanceof Error ? error : new Error(String(error)) + lastError = + error instanceof Error + ? error + : new Error( + typeof error === 'object' && + error !== null && + typeof (error as any).message === 'string' + ? (error as any).message + : String(error), + { cause: error }, + ) this.#setState({ lastError }) // Call onError for every error (including during retries) diff --git a/packages/pacer/tests/async-retryer.test.ts b/packages/pacer/tests/async-retryer.test.ts index 448ea9194..1b75d8aa4 100644 --- a/packages/pacer/tests/async-retryer.test.ts +++ b/packages/pacer/tests/async-retryer.test.ts @@ -387,6 +387,24 @@ describe('AsyncRetryer', () => { expect(onLastError).toHaveBeenCalledTimes(1) expect(onLastError).toHaveBeenCalledWith(error, retryer) }) + + it('should preserve plain object error message and cause', async () => { + const originalError = { message: 'readable error', code: 'E001' } + const mockFn = vi.fn().mockRejectedValue(originalError) + const retryer = new AsyncRetryer(mockFn, { + maxAttempts: 1, + throwOnError: true, + }) + + try { + await retryer.execute() + expect.unreachable('Expected execute to throw') + } catch (error) { + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe('readable error') + expect((error as Error & { cause?: unknown }).cause).toBe(originalError) + } + }) }) describe('State Management', () => {