From 798864f3c1548b9b94544d54ca16ff507b92051f Mon Sep 17 00:00:00 2001 From: LiteSun Date: Tue, 12 May 2026 16:52:12 +0800 Subject: [PATCH 1/4] fix: improve timeout error message with request details When a timeout error occurs, the error message now includes the HTTP method, URL, timeout duration, and a hint to use the --timeout flag. This applies to all backends (API7, APISIX, APISIX Standalone). Before: AxiosError: timeout of 10000ms exceeded After: Request "GET https://example.com/api/version" timed out after 10000ms. Consider increasing the timeout with the --timeout flag. Closes #442 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- libs/backend-api7/src/index.ts | 2 + libs/backend-apisix-standalone/src/index.ts | 2 + libs/backend-apisix/src/index.ts | 2 + libs/sdk/src/utils.spec.ts | 51 +++++++++++++++++++++ libs/sdk/src/utils.ts | 21 +++++++++ 5 files changed, 78 insertions(+) diff --git a/libs/backend-api7/src/index.ts b/libs/backend-api7/src/index.ts index 3e273175..dd1db4c1 100644 --- a/libs/backend-api7/src/index.ts +++ b/libs/backend-api7/src/index.ts @@ -33,6 +33,8 @@ export class BackendAPI7 implements ADCSDK.Backend { ...(opts.timeout ? { timeout: opts.timeout } : {}), }); this.gatewayGroupName = opts.gatewayGroup!; + + ADCSDK.utils.registerTimeoutInterceptor(this.client); } public metadata() { diff --git a/libs/backend-apisix-standalone/src/index.ts b/libs/backend-apisix-standalone/src/index.ts index 56fef9a4..1d678e12 100644 --- a/libs/backend-apisix-standalone/src/index.ts +++ b/libs/backend-apisix-standalone/src/index.ts @@ -40,6 +40,8 @@ export class BackendAPISIXStandalone implements ADCSDK.Backend { httpsAgent: opts.httpsAgent, ...(opts.timeout ? { timeout: opts.timeout } : {}), }); + + ADCSDK.utils.registerTimeoutInterceptor(this.client); } public metadata() { diff --git a/libs/backend-apisix/src/index.ts b/libs/backend-apisix/src/index.ts index ba524e68..d89af8b9 100644 --- a/libs/backend-apisix/src/index.ts +++ b/libs/backend-apisix/src/index.ts @@ -27,6 +27,8 @@ export class BackendAPISIX implements ADCSDK.Backend { httpsAgent: opts.httpsAgent, ...(opts.timeout ? { timeout: opts.timeout } : {}), }); + + ADCSDK.utils.registerTimeoutInterceptor(this.client); } public metadata() { diff --git a/libs/sdk/src/utils.spec.ts b/libs/sdk/src/utils.spec.ts index 413fd06f..c69a71fe 100644 --- a/libs/sdk/src/utils.spec.ts +++ b/libs/sdk/src/utils.spec.ts @@ -1,3 +1,5 @@ +import axios from 'axios'; + import { utils } from './utils'; describe('SDK utils', () => { @@ -18,4 +20,53 @@ describe('SDK utils', () => { test5: ['test', undefined], }); }); + + describe('registerTimeoutInterceptor', () => { + it('should enhance timeout error message with request details', async () => { + const client = axios.create({ + baseURL: 'https://example.com', + timeout: 5000, + }); + utils.registerTimeoutInterceptor(client); + + const timeoutError = new axios.AxiosError( + 'timeout of 5000ms exceeded', + 'ECONNABORTED', + { + method: 'get', + url: '/api/gateway_groups', + baseURL: 'https://example.com', + timeout: 5000, + headers: new axios.AxiosHeaders(), + }, + ); + + // Simulate the interceptor by manually invoking it + const interceptor = client.interceptors.response as any; + const handlers = interceptor.handlers; + const rejectedHandler = handlers[handlers.length - 1].rejected; + + await expect(rejectedHandler(timeoutError)).rejects.toThrow( + 'Request "GET https://example.com/api/gateway_groups" timed out after 5000ms. Consider increasing the timeout with the --timeout flag.', + ); + }); + + it('should not modify non-timeout errors', async () => { + const client = axios.create({ baseURL: 'https://example.com' }); + utils.registerTimeoutInterceptor(client); + + const nonTimeoutError = new axios.AxiosError( + 'Request failed with status code 500', + 'ERR_BAD_RESPONSE', + ); + + const interceptor = client.interceptors.response as any; + const handlers = interceptor.handlers; + const rejectedHandler = handlers[handlers.length - 1].rejected; + + await expect(rejectedHandler(nonTimeoutError)).rejects.toThrow( + 'Request failed with status code 500', + ); + }); + }); }); diff --git a/libs/sdk/src/utils.ts b/libs/sdk/src/utils.ts index 6261f249..2c3d5490 100644 --- a/libs/sdk/src/utils.ts +++ b/libs/sdk/src/utils.ts @@ -1,3 +1,4 @@ +import axios, { type AxiosInstance } from 'axios'; import { isUndefined, mapValues, pickBy } from 'lodash-es'; import { createHash } from 'node:crypto'; @@ -28,9 +29,29 @@ const featureGateEnabled = (key: string) => { ); }; +const registerTimeoutInterceptor = (client: AxiosInstance) => { + client.interceptors.response.use(undefined, (error) => { + if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') { + const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN'; + const url = `${error.config?.baseURL ?? ''}${error.config?.url ?? ''}`; + const timeout = error.config?.timeout; + const newMessage = `Request "${method} ${url}" timed out after ${timeout}ms. Consider increasing the timeout with the --timeout flag.`; + error.message = newMessage; + if (error.stack) { + error.stack = error.stack.replace( + /^(.*?):\s*(.*)/, + `$1: ${newMessage}`, + ); + } + } + return Promise.reject(error); + }); +}; + export const utils = { generateId, recursiveOmitUndefined, featureGate, featureGateEnabled, + registerTimeoutInterceptor, }; From b8dcdfd973b347d628b2fb22b3a7b48adcbffdff Mon Sep 17 00:00:00 2001 From: LiteSun Date: Tue, 12 May 2026 16:55:07 +0800 Subject: [PATCH 2/4] test: add timeout error message tests for BackendAPI7 Add integration-level tests using a local HTTP server with delayed responses to verify that timeout errors include clear, actionable messages across all BackendAPI7 operations: ping, version, dump, sync. Also enhance SDK unit test to verify stack trace is updated alongside the error message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- libs/backend-api7/test/timeout.spec.ts | 117 +++++++++++++++++++++++++ libs/sdk/src/utils.spec.ts | 15 +++- 2 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 libs/backend-api7/test/timeout.spec.ts diff --git a/libs/backend-api7/test/timeout.spec.ts b/libs/backend-api7/test/timeout.spec.ts new file mode 100644 index 00000000..0cf3eedc --- /dev/null +++ b/libs/backend-api7/test/timeout.spec.ts @@ -0,0 +1,117 @@ +import * as ADCSDK from '@api7/adc-sdk'; +import { Agent as httpAgent } from 'http'; +import { Agent as httpsAgent } from 'https'; +import { createServer } from 'node:http'; +import { lastValueFrom, toArray } from 'rxjs'; +import { AddressInfo } from 'node:net'; + +import { BackendAPI7 } from '../src'; + +describe('BackendAPI7 timeout error message', () => { + // Create a local HTTP server that delays responses to trigger timeouts + let server: ReturnType; + let serverUrl: string; + + beforeAll(async () => { + server = createServer((_, res) => { + // Delay response by 5 seconds to guarantee timeout + setTimeout(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ value: {} })); + }, 5000); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = server.address() as AddressInfo; + serverUrl = `http://127.0.0.1:${addr.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + const createBackend = (timeout: number) => + new BackendAPI7({ + server: serverUrl, + token: 'test-token', + gatewayGroup: 'default', + timeout, + cacheKey: 'test', + httpAgent: new httpAgent({ keepAlive: false }), + httpsAgent: new httpsAgent({ keepAlive: false }), + }); + + it('ping timeout should include request URL and timeout hint', async () => { + const backend = createBackend(10); + try { + await backend.ping(); + throw new Error('Expected timeout error'); + } catch (err) { + const error = err as Error; + expect(error.message).toContain( + `${serverUrl}/api/gateway_groups`, + ); + expect(error.message).toContain('timed out after 10ms'); + expect(error.message).toContain('--timeout'); + } + }); + + it('version timeout should include request URL and timeout hint', async () => { + const backend = createBackend(10); + try { + await backend.version(); + throw new Error('Expected timeout error'); + } catch (err) { + const error = err as Error; + expect(error.message).toContain(`${serverUrl}/api/version`); + expect(error.message).toContain('timed out after 10ms'); + expect(error.message).toContain('--timeout'); + } + }); + + it('dump timeout should include request URL and timeout hint', async () => { + const backend = createBackend(10); + try { + await lastValueFrom(backend.dump().pipe(toArray())); + throw new Error('Expected timeout error'); + } catch (err) { + const error = err as Error; + expect(error.message).toContain(`${serverUrl}/api/`); + expect(error.message).toContain('timed out after 10ms'); + expect(error.message).toContain('--timeout'); + } + }); + + it('sync timeout should include request URL and timeout hint', async () => { + const backend = createBackend(10); + try { + await lastValueFrom( + backend + .sync( + [ + { + type: ADCSDK.EventType.CREATE, + resourceType: ADCSDK.ResourceType.CONSUMER, + resourceId: 'test-consumer', + resourceName: 'test-consumer', + newValue: { + username: 'test', + plugins: {}, + }, + }, + ], + { exitOnFailure: true }, + ) + .pipe(toArray()), + ); + throw new Error('Expected timeout error'); + } catch (err) { + const error = err as Error; + // sync first calls version() and getGatewayGroupId(), which will timeout + expect(error.message).toContain(`${serverUrl}/api/`); + expect(error.message).toContain('timed out after 10ms'); + expect(error.message).toContain('--timeout'); + } + }); +}); diff --git a/libs/sdk/src/utils.spec.ts b/libs/sdk/src/utils.spec.ts index c69a71fe..c2655486 100644 --- a/libs/sdk/src/utils.spec.ts +++ b/libs/sdk/src/utils.spec.ts @@ -41,14 +41,21 @@ describe('SDK utils', () => { }, ); - // Simulate the interceptor by manually invoking it const interceptor = client.interceptors.response as any; const handlers = interceptor.handlers; const rejectedHandler = handlers[handlers.length - 1].rejected; - await expect(rejectedHandler(timeoutError)).rejects.toThrow( - 'Request "GET https://example.com/api/gateway_groups" timed out after 5000ms. Consider increasing the timeout with the --timeout flag.', - ); + try { + await rejectedHandler(timeoutError); + throw new Error('Expected rejection'); + } catch (err) { + const error = err as Error; + const expectedMsg = + 'Request "GET https://example.com/api/gateway_groups" timed out after 5000ms. Consider increasing the timeout with the --timeout flag.'; + expect(error.message).toBe(expectedMsg); + // Stack trace should also contain the updated message + expect(error.stack).toContain(expectedMsg); + } }); it('should not modify non-timeout errors', async () => { From e472ecff32a69cd92d67a00112968f7f51824c15 Mon Sep 17 00:00:00 2001 From: LiteSun Date: Tue, 12 May 2026 17:01:48 +0800 Subject: [PATCH 3/4] fix: handle absolute URLs and missing timeout defensively Address CodeRabbit review feedback: - Detect absolute URLs (http(s)://) and avoid duplicating baseURL - Handle missing timeout value gracefully (show 'an unknown duration' instead of 'undefinedms') - Add test cases for both edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- libs/sdk/src/utils.spec.ts | 72 ++++++++++++++++++++++++++++++++------ libs/sdk/src/utils.ts | 9 +++-- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/libs/sdk/src/utils.spec.ts b/libs/sdk/src/utils.spec.ts index c2655486..c83d5d48 100644 --- a/libs/sdk/src/utils.spec.ts +++ b/libs/sdk/src/utils.spec.ts @@ -22,6 +22,12 @@ describe('SDK utils', () => { }); describe('registerTimeoutInterceptor', () => { + const getInterceptorHandler = (client: ReturnType) => { + const interceptor = client.interceptors.response as any; + const handlers = interceptor.handlers; + return handlers[handlers.length - 1].rejected; + }; + it('should enhance timeout error message with request details', async () => { const client = axios.create({ baseURL: 'https://example.com', @@ -41,23 +47,71 @@ describe('SDK utils', () => { }, ); - const interceptor = client.interceptors.response as any; - const handlers = interceptor.handlers; - const rejectedHandler = handlers[handlers.length - 1].rejected; - try { - await rejectedHandler(timeoutError); + await getInterceptorHandler(client)(timeoutError); throw new Error('Expected rejection'); } catch (err) { const error = err as Error; const expectedMsg = 'Request "GET https://example.com/api/gateway_groups" timed out after 5000ms. Consider increasing the timeout with the --timeout flag.'; expect(error.message).toBe(expectedMsg); - // Stack trace should also contain the updated message expect(error.stack).toContain(expectedMsg); } }); + it('should handle absolute URL without duplicating baseURL', async () => { + const client = axios.create({ baseURL: 'https://example.com' }); + utils.registerTimeoutInterceptor(client); + + const timeoutError = new axios.AxiosError( + 'timeout of 3000ms exceeded', + 'ECONNABORTED', + { + method: 'put', + url: 'https://other-server.com/api/services/123', + baseURL: 'https://example.com', + timeout: 3000, + headers: new axios.AxiosHeaders(), + }, + ); + + try { + await getInterceptorHandler(client)(timeoutError); + throw new Error('Expected rejection'); + } catch (err) { + const error = err as Error; + expect(error.message).toContain( + 'https://other-server.com/api/services/123', + ); + expect(error.message).not.toContain('https://example.com'); + } + }); + + it('should handle missing timeout value gracefully', async () => { + const client = axios.create({ baseURL: 'https://example.com' }); + utils.registerTimeoutInterceptor(client); + + const timeoutError = new axios.AxiosError( + 'timeout exceeded', + 'ECONNABORTED', + { + method: 'get', + url: '/api/version', + baseURL: 'https://example.com', + headers: new axios.AxiosHeaders(), + }, + ); + + try { + await getInterceptorHandler(client)(timeoutError); + throw new Error('Expected rejection'); + } catch (err) { + const error = err as Error; + expect(error.message).toContain('timed out after an unknown duration'); + expect(error.message).not.toContain('undefinedms'); + } + }); + it('should not modify non-timeout errors', async () => { const client = axios.create({ baseURL: 'https://example.com' }); utils.registerTimeoutInterceptor(client); @@ -67,11 +121,7 @@ describe('SDK utils', () => { 'ERR_BAD_RESPONSE', ); - const interceptor = client.interceptors.response as any; - const handlers = interceptor.handlers; - const rejectedHandler = handlers[handlers.length - 1].rejected; - - await expect(rejectedHandler(nonTimeoutError)).rejects.toThrow( + await expect(getInterceptorHandler(client)(nonTimeoutError)).rejects.toThrow( 'Request failed with status code 500', ); }); diff --git a/libs/sdk/src/utils.ts b/libs/sdk/src/utils.ts index 2c3d5490..b08e26c9 100644 --- a/libs/sdk/src/utils.ts +++ b/libs/sdk/src/utils.ts @@ -33,9 +33,14 @@ const registerTimeoutInterceptor = (client: AxiosInstance) => { client.interceptors.response.use(undefined, (error) => { if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') { const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN'; - const url = `${error.config?.baseURL ?? ''}${error.config?.url ?? ''}`; + const rawUrl = error.config?.url ?? ''; + const url = /^https?:\/\//i.test(rawUrl) + ? rawUrl + : `${error.config?.baseURL ?? ''}${rawUrl}`; const timeout = error.config?.timeout; - const newMessage = `Request "${method} ${url}" timed out after ${timeout}ms. Consider increasing the timeout with the --timeout flag.`; + const timeoutText = + typeof timeout === 'number' ? `${timeout}ms` : 'an unknown duration'; + const newMessage = `Request "${method} ${url}" timed out after ${timeoutText}. Consider increasing the timeout with the --timeout flag.`; error.message = newMessage; if (error.stack) { error.stack = error.stack.replace( From e015bb3928dcb75765ab7a102f878f72c85dd8ab Mon Sep 17 00:00:00 2001 From: LiteSun Date: Wed, 13 May 2026 15:43:30 +0800 Subject: [PATCH 4/4] feat: improve error messages for HTTP responses without error_msg When the API7 Dashboard returns non-2xx responses with empty body (e.g., HTTP 500 under high concurrency), the error message was displayed as 'Error: ""' which provides no useful diagnostic info. The root cause is that JSON.stringify('') produces '""', and when error.response.data is an empty string, error_msg is undefined, falling through to JSON.stringify. This commit adds formatAxiosErrorMessage() to the SDK utils that always includes HTTP method, URL, status code, and status text in error messages. When error_msg is available, it is included; when the response body is empty or lacks error_msg, the status code and URL still provide useful diagnostic context. Applied to all three backends: api7, apisix, apisix-standalone. Closes #442 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- libs/backend-api7/src/operator.ts | 9 +- .../backend-apisix-standalone/src/operator.ts | 9 +- libs/backend-apisix/src/operator.ts | 9 +- libs/sdk/src/utils.spec.ts | 111 ++++++++++++++++++ libs/sdk/src/utils.ts | 45 +++++++ 5 files changed, 171 insertions(+), 12 deletions(-) diff --git a/libs/backend-api7/src/operator.ts b/libs/backend-api7/src/operator.ts index ecd030b0..dfd71ee4 100644 --- a/libs/backend-api7/src/operator.ts +++ b/libs/backend-api7/src/operator.ts @@ -95,8 +95,7 @@ export class Operator extends ADCSDK.backend.BackendEventSource { return throwError( () => new Error( - error.response?.data?.error_msg ?? - JSON.stringify(error.response?.data), + ADCSDK.utils.formatAxiosErrorMessage(error), ), ); return throwError(() => error); @@ -107,8 +106,10 @@ export class Operator extends ADCSDK.backend.BackendEventSource { error, ...(axios.isAxiosError(error) && { axiosResponse: error.response, - ...(error.response?.data?.error_msg && { - error: new Error(error.response.data.error_msg), + ...(error.response && { + error: new Error( + ADCSDK.utils.formatAxiosErrorMessage(error), + ), }), }), } satisfies ADCSDK.BackendSyncResult); diff --git a/libs/backend-apisix-standalone/src/operator.ts b/libs/backend-apisix-standalone/src/operator.ts index 8cffa315..0466707f 100644 --- a/libs/backend-apisix-standalone/src/operator.ts +++ b/libs/backend-apisix-standalone/src/operator.ts @@ -153,8 +153,7 @@ export class Operator extends ADCSDK.backend.BackendEventSource { return throwError( () => new Error( - error.response?.data?.error_msg ?? - JSON.stringify(error.response?.data), + ADCSDK.utils.formatAxiosErrorMessage(error), ), ); return throwError(() => error); @@ -165,8 +164,10 @@ export class Operator extends ADCSDK.backend.BackendEventSource { error, ...(axios.isAxiosError(error) && { axiosResponse: error.response, - ...(error.response?.data?.error_msg && { - error: new Error(error.response.data.error_msg), + ...(error.response && { + error: new Error( + ADCSDK.utils.formatAxiosErrorMessage(error), + ), }), }), server, diff --git a/libs/backend-apisix/src/operator.ts b/libs/backend-apisix/src/operator.ts index 3f66abff..6fcb83cb 100644 --- a/libs/backend-apisix/src/operator.ts +++ b/libs/backend-apisix/src/operator.ts @@ -162,8 +162,7 @@ export class Operator extends ADCSDK.backend.BackendEventSource { return throwError( () => new Error( - error.response?.data?.error_msg ?? - JSON.stringify(error.response?.data), + ADCSDK.utils.formatAxiosErrorMessage(error), ), ); return throwError(() => error); @@ -174,8 +173,10 @@ export class Operator extends ADCSDK.backend.BackendEventSource { error, ...(axios.isAxiosError(error) && { axiosResponse: error.response, - ...(error.response?.data?.error_msg && { - error: new Error(error.response.data.error_msg), + ...(error.response && { + error: new Error( + ADCSDK.utils.formatAxiosErrorMessage(error), + ), }), }), } satisfies ADCSDK.BackendSyncResult); diff --git a/libs/sdk/src/utils.spec.ts b/libs/sdk/src/utils.spec.ts index c83d5d48..68018da9 100644 --- a/libs/sdk/src/utils.spec.ts +++ b/libs/sdk/src/utils.spec.ts @@ -126,4 +126,115 @@ describe('SDK utils', () => { ); }); }); + + describe('formatAxiosErrorMessage', () => { + it('should format error with error_msg from response data', () => { + const error = { + config: { + method: 'put', + url: '/apisix/admin/routes/123', + baseURL: 'https://example.com', + }, + response: { + status: 400, + statusText: 'Bad Request', + data: { error_msg: 'route name is reduplicate' }, + }, + }; + const msg = utils.formatAxiosErrorMessage(error); + expect(msg).toBe( + 'PUT https://example.com/apisix/admin/routes/123, responded with status 400 Bad Request, error_msg: route name is reduplicate', + ); + }); + + it('should format error when response body is empty string (Error: "" scenario)', () => { + const error = { + config: { + method: 'put', + url: '/apisix/admin/routes/456', + baseURL: 'https://dashboard.example.com', + }, + response: { + status: 500, + statusText: 'Internal Server Error', + data: '', + }, + }; + const msg = utils.formatAxiosErrorMessage(error); + expect(msg).toBe( + 'PUT https://dashboard.example.com/apisix/admin/routes/456, responded with status 500 Internal Server Error', + ); + }); + + it('should include response body when no error_msg field exists', () => { + const error = { + config: { + method: 'get', + url: '/api/services', + baseURL: 'https://example.com', + }, + response: { + status: 502, + statusText: 'Bad Gateway', + data: 'Bad Gateway', + }, + }; + const msg = utils.formatAxiosErrorMessage(error); + expect(msg).toContain('responded with status 502 Bad Gateway'); + expect(msg).toContain( + 'response body: Bad Gateway', + ); + }); + + it('should handle absolute URL without duplicating baseURL', () => { + const error = { + config: { + method: 'delete', + url: 'https://other.com/api/routes/789', + baseURL: 'https://example.com', + }, + response: { + status: 404, + statusText: 'Not Found', + data: { error_msg: 'not found' }, + }, + }; + const msg = utils.formatAxiosErrorMessage(error); + expect(msg).toContain('DELETE https://other.com/api/routes/789'); + expect(msg).not.toContain('https://example.com'); + }); + + it('should handle missing config gracefully', () => { + const error = { + response: { + status: 500, + statusText: 'Internal Server Error', + data: null, + }, + }; + const msg = utils.formatAxiosErrorMessage(error); + expect(msg).toContain('UNKNOWN'); + expect(msg).toContain('responded with status 500'); + }); + + it('should handle JSON object response without error_msg', () => { + const error = { + config: { + method: 'post', + url: '/api/consumers', + baseURL: 'https://example.com', + }, + response: { + status: 409, + statusText: 'Conflict', + data: { code: 10001, message: 'conflict detected' }, + }, + }; + const msg = utils.formatAxiosErrorMessage(error); + expect(msg).toContain('responded with status 409 Conflict'); + expect(msg).toContain( + 'response body: {"code":10001,"message":"conflict detected"}', + ); + }); + }); }); diff --git a/libs/sdk/src/utils.ts b/libs/sdk/src/utils.ts index b08e26c9..3ef181c1 100644 --- a/libs/sdk/src/utils.ts +++ b/libs/sdk/src/utils.ts @@ -29,6 +29,50 @@ const featureGateEnabled = (key: string) => { ); }; +const formatAxiosErrorMessage = (error: { + response?: { + status?: number; + statusText?: string; + data?: unknown; + config?: { method?: string; url?: string; baseURL?: string }; + }; + config?: { method?: string; url?: string; baseURL?: string }; +}): string => { + const config = error.config ?? error.response?.config; + const method = config?.method?.toUpperCase() ?? 'UNKNOWN'; + const rawUrl = config?.url ?? ''; + const url = /^https?:\/\//i.test(rawUrl) + ? rawUrl + : `${config?.baseURL ?? ''}${rawUrl}`; + const status = error.response?.status; + const statusText = error.response?.statusText ?? ''; + + const errorMsg = + typeof error.response?.data === 'object' && + error.response?.data !== null && + 'error_msg' in (error.response.data as Record) + ? (error.response.data as Record).error_msg + : undefined; + + const parts: Array = []; + parts.push(`${method} ${url}`); + if (status) parts.push(`responded with status ${status} ${statusText}`.trim()); + + if (errorMsg) { + parts.push(`error_msg: ${errorMsg}`); + } else { + const body = + typeof error.response?.data === 'string' + ? error.response.data + : JSON.stringify(error.response?.data); + if (body && body !== '""' && body !== 'undefined') { + parts.push(`response body: ${body}`); + } + } + + return parts.join(', '); +}; + const registerTimeoutInterceptor = (client: AxiosInstance) => { client.interceptors.response.use(undefined, (error) => { if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') { @@ -59,4 +103,5 @@ export const utils = { featureGate, featureGateEnabled, registerTimeoutInterceptor, + formatAxiosErrorMessage, };