From 0b462906c2e4deb781eb3153309bff9124f12daf Mon Sep 17 00:00:00 2001 From: awwit Date: Wed, 4 Mar 2026 00:08:58 +0300 Subject: [PATCH 1/2] fix: capture the initial stacktrace to track request errors feat: use the original error as the cause of the top-level error test: adapt cases with error handling --- src/client.test.ts | 32 +++++++++++++++++++++---- src/client.ts | 60 +++++++++++++++++++++++++++++++++++++++------- tsconfig.json | 2 +- 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/src/client.test.ts b/src/client.test.ts index 35ce1cd..e13462b 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -237,7 +237,13 @@ describe('Client', () => { await expect( client.request(HttpMethod.get, '/products/:count', {}), - ).rejects.toThrow(new Error('Internal Server Error')); + ).rejects.toThrow( + new Error('Internal Server Error', { + cause: new Error('Request failed with status code 500', { + cause: new Error('Request failed with status code 500'), + }), + }), + ); }); test('handles a timeout', async () => { @@ -247,7 +253,13 @@ describe('Client', () => { await expect( client.request(HttpMethod.get, '/products/:count', {}), - ).rejects.toThrow(new Error('timeout of 0ms exceeded')); + ).rejects.toThrow( + new Error('timeout of 0ms exceeded', { + cause: new Error('timeout of 0ms exceeded', { + cause: new Error('timeout of 0ms exceeded'), + }), + }), + ); }); }); // describe: #request @@ -260,7 +272,13 @@ describe('Client', () => { await expect( client.request(HttpMethod.get, '/products/:count', {}), - ).rejects.toThrow(new Error('timeout of 0ms exceeded')); + ).rejects.toThrow( + new Error('timeout of 0ms exceeded', { + cause: new Error('timeout of 0ms exceeded', { + cause: new Error('timeout of 0ms exceeded'), + }), + }), + ); }); test('handle retries option', async () => { @@ -301,7 +319,13 @@ describe('Client', () => { await expect( client.request(HttpMethod.get, '/categories/:count', {}), - ).rejects.toThrow(new Error('timeout of 0ms exceeded')); + ).rejects.toThrow( + new Error('timeout of 0ms exceeded', { + cause: new Error('timeout of 0ms exceeded', { + cause: new Error('timeout of 0ms exceeded'), + }), + }), + ); }); test('handle return error code without retries', async () => { diff --git a/src/client.ts b/src/client.ts index 21788fe..d388c63 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,4 @@ -import * as axios from 'axios'; +import axios from 'axios'; import * as retry from 'retry'; import { CookieJar } from 'tough-cookie'; import { HttpCookieAgent, HttpsCookieAgent } from 'http-cookie-agent/http'; @@ -63,7 +63,6 @@ const DEFAULT_OPTIONS: Readonly = Object.freeze({ }); class ApiError extends Error { - message: string; code?: string; status?: number; headers: HttpHeaders; @@ -73,10 +72,10 @@ class ApiError extends Error { code?: string, status?: number, headers: HttpHeaders = {}, + cause?: unknown, ) { - super(); + super(message, cause ? { cause } : undefined); - this.message = message; this.code = code; this.status = status; this.headers = headers; @@ -305,7 +304,10 @@ export class Client { // Prepare url and data for request const requestParams = transformRequest(method, url, data, headers); - return new Promise((resolve, reject) => { + const executor = ( + resolve: (value: T) => void, + reject: (reason: unknown) => void, + ) => { const { retries } = this.options; const operation = retry.operation({ @@ -316,6 +318,9 @@ export class Client { randomize: false, }); + // Remember stacktrace + const stacktrace = captureStackTrace(executor); + operation.attempt(async () => { if (this.httpClient === null) { return reject(new Error('Swell API client not initialized')); @@ -341,13 +346,15 @@ export class Client { ) { return; } - reject(transformError(error)); + reject(transformError(error, stacktrace)); } finally { // Decrement active request counter clientWrapper.activeRequests--; } }); - }); + }; + + return new Promise(executor); } /** @@ -426,13 +433,41 @@ function isError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error; } +// eslint-disable-next-line @typescript-eslint/ban-types +function captureStackTrace(constructorOpt?: Function): string { + if (typeof Error.captureStackTrace === 'function') { + const error = { stack: '' }; + Error.captureStackTrace(error, constructorOpt || captureStackTrace); + return error.stack; + } + + // Fallback to extract stacktrace from Error + let stack = new Error('123').stack || ''; + let pos = stack.indexOf('\n'); + + if (pos !== -1) { + const start = pos; + + // Cut 2 lines from the stacktrace + pos = stack.indexOf('\n', pos + 1); + pos = stack.indexOf('\n', pos + 1); + + if (pos !== -1) { + stack = stack.slice(0, start) + stack.slice(pos); + } + } + + return stack; +} + /** * Transforms the error response. * * @param error The Error object + * @param stacktrace The stacktrace of the request * @return {ApiError} */ -function transformError(error: unknown): ApiError { +function transformError(error: unknown, stacktrace?: string): ApiError { let code, message = '', status, @@ -464,12 +499,19 @@ function transformError(error: unknown): ApiError { message = error.message; } - return new ApiError( + const apiError = new ApiError( message, typeof code === 'string' ? code.toUpperCase().replace(/ /g, '_') : 'ERROR', status, headers, + error, ); + + if (stacktrace) { + apiError.stack = stacktrace; + } + + return apiError; } function normalizeHeaders( diff --git a/tsconfig.json b/tsconfig.json index c4ed49e..3b7a86f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "rootDir": "./src", "outDir": "./dist", "declaration": true, - "lib": ["es2020"], + "lib": ["es2022"], "module": "node16", "target": "es2020", "strict": true, From 0ae5faf81d083a83ea5313b62cf1bdddf0e05cb0 Mon Sep 17 00:00:00 2001 From: awwit Date: Wed, 4 Mar 2026 17:46:21 +0300 Subject: [PATCH 2/2] fix: add error type and message to saved stacktrace --- src/client.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/client.ts b/src/client.ts index d388c63..c6a5d47 100644 --- a/src/client.ts +++ b/src/client.ts @@ -438,22 +438,21 @@ function captureStackTrace(constructorOpt?: Function): string { if (typeof Error.captureStackTrace === 'function') { const error = { stack: '' }; Error.captureStackTrace(error, constructorOpt || captureStackTrace); - return error.stack; + // Remove first line with empty "Error" + return error.stack.slice(error.stack.indexOf('\n') + 1); } // Fallback to extract stacktrace from Error - let stack = new Error('123').stack || ''; + let stack = new Error('').stack || ''; let pos = stack.indexOf('\n'); if (pos !== -1) { - const start = pos; - // Cut 2 lines from the stacktrace pos = stack.indexOf('\n', pos + 1); pos = stack.indexOf('\n', pos + 1); if (pos !== -1) { - stack = stack.slice(0, start) + stack.slice(pos); + stack = stack.slice(pos + 1); } } @@ -508,7 +507,7 @@ function transformError(error: unknown, stacktrace?: string): ApiError { ); if (stacktrace) { - apiError.stack = stacktrace; + apiError.stack = apiError.toString() + '\n' + stacktrace; } return apiError;