diff --git a/src/nodejs-common/util.ts b/src/nodejs-common/util.ts index aec0b2f50..d4425e76c 100644 --- a/src/nodejs-common/util.ts +++ b/src/nodejs-common/util.ts @@ -256,6 +256,12 @@ export interface ParsedHttpResponseBody { err?: Error; } +export enum UtilExceptionMessages { + TLS_TIMEOUT_ERROR_MESSAGE = 'Request or TLS handshake timed out. This may be due to CPU starvation or a temporary network issue.', + ETIMEDOUT_ERROR_MESSAGE = 'Connection timed out. This suggests a network issue or the server took too long to respond.', + ECONNRESET_ERROR_MESSAGE = 'Connection reset by peer. The connection was closed unexpectedly, possibly by a firewall or network issue.', +} + /** * Custom error type for API errors. * @@ -912,6 +918,33 @@ export class Util { options, // eslint-disable-next-line @typescript-eslint/no-explicit-any (err: Error | null, response: {}, body: any) => { + if (err) { + const lowerCaseMessage = err.message.toLowerCase(); + let newError: Error | undefined; + + if (lowerCaseMessage.includes('tls handshake')) { + newError = new Error( + UtilExceptionMessages.TLS_TIMEOUT_ERROR_MESSAGE + ); + } else if ( + lowerCaseMessage.includes('etimedout') || + lowerCaseMessage.includes('timed out') + ) { + newError = new Error( + UtilExceptionMessages.ETIMEDOUT_ERROR_MESSAGE + ); + } else if (lowerCaseMessage.includes('econnreset')) { + newError = new Error( + UtilExceptionMessages.ECONNRESET_ERROR_MESSAGE + ); + } + + if (newError) { + // Preserve the original stack trace for better debugging + newError.stack = err.stack; + err = newError; + } + } util.handleResp(err, response as {} as r.Response, body, callback!); } ); diff --git a/test/nodejs-common/util.ts b/test/nodejs-common/util.ts index 3efc73d11..58c107f35 100644 --- a/test/nodejs-common/util.ts +++ b/test/nodejs-common/util.ts @@ -46,6 +46,7 @@ import { MakeRequestConfig, ParsedHttpRespMessage, Util, + UtilExceptionMessages, } from '../../src/nodejs-common/util.js'; import {DEFAULT_PROJECT_ID_TOKEN} from '../../src/nodejs-common/service.js'; import duplexify from 'duplexify'; @@ -1189,6 +1190,62 @@ describe('common/util', () => { }); }); + describe('Handling of TLS Handshake, Timeout, and Connection Reset Errors in Authenticated Requests', () => { + const reqOpts = fakeReqOpts; + beforeEach(() => { + authClient.authorizeRequest = async () => reqOpts; + sandbox.stub(fakeGoogleAuth, 'GoogleAuth').returns(authClient); + }); + + const testCases = [ + { + name: 'ECONNRESET', + error: new Error('ECONNRESET'), + expectedMessage: UtilExceptionMessages.ECONNRESET_ERROR_MESSAGE, + }, + { + name: '"TLS handshake"', + error: new Error('Request failed due to TLS handshake timeout.'), + expectedMessage: UtilExceptionMessages.TLS_TIMEOUT_ERROR_MESSAGE, + }, + { + name: 'generic "timed out"', + error: new Error('The request timed out.'), + expectedMessage: UtilExceptionMessages.ETIMEDOUT_ERROR_MESSAGE, + }, + { + name: 'ETIMEDOUT', + error: new Error('Request failed with error: ETIMEDOUT'), + expectedMessage: UtilExceptionMessages.ETIMEDOUT_ERROR_MESSAGE, + }, + ]; + + testCases.forEach(({name, error: networkError, expectedMessage}) => { + it(`should transform raw ${name} into specific network error`, done => { + // Override `retry-request` to simulate a network error. + retryRequestOverride = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _reqOpts: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _opts: any, + callback: (err: Error, res: {}, body: null) => void + ) => { + callback(networkError, {}, null); + return {abort: () => {}}; // Return an abortable request. + }; + + const makeAuthenticatedRequest = + util.makeAuthenticatedRequestFactory({}); + + makeAuthenticatedRequest({} as DecorateRequestOptions, err => { + assert.ok(err); + assert.strictEqual(err!.message, expectedMessage); + done(); + }); + }); + }); + }); + describe('authentication success', () => { const reqOpts = fakeReqOpts; beforeEach(() => {