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-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-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/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-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/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/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 413fd06f..68018da9 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,221 @@ describe('SDK utils', () => { test5: ['test', undefined], }); }); + + 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', + 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(), + }, + ); + + try { + 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); + 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); + + const nonTimeoutError = new axios.AxiosError( + 'Request failed with status code 500', + 'ERR_BAD_RESPONSE', + ); + + await expect(getInterceptorHandler(client)(nonTimeoutError)).rejects.toThrow( + 'Request failed with status code 500', + ); + }); + }); + + 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 6261f249..3ef181c1 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,79 @@ 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') { + const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN'; + const rawUrl = error.config?.url ?? ''; + const url = /^https?:\/\//i.test(rawUrl) + ? rawUrl + : `${error.config?.baseURL ?? ''}${rawUrl}`; + const timeout = error.config?.timeout; + 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( + /^(.*?):\s*(.*)/, + `$1: ${newMessage}`, + ); + } + } + return Promise.reject(error); + }); +}; + export const utils = { generateId, recursiveOmitUndefined, featureGate, featureGateEnabled, + registerTimeoutInterceptor, + formatAxiosErrorMessage, };