From d32c9b77df5cb4c853dd1059c2e820315e949e5f Mon Sep 17 00:00:00 2001 From: PROTHAM <155388736+ProthamD@users.noreply.github.com> Date: Fri, 15 May 2026 21:56:03 +0530 Subject: [PATCH] Fix/web fetch ctrl c abort (#24320) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/utils/fetch.test.ts | 56 ++++++++++++++++++++++----- packages/core/src/utils/fetch.ts | 12 +++++- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/core/src/utils/fetch.test.ts b/packages/core/src/utils/fetch.test.ts index 1f56f7af19..f14f75eea3 100644 --- a/packages/core/src/utils/fetch.test.ts +++ b/packages/core/src/utils/fetch.test.ts @@ -5,7 +5,7 @@ */ import { updateGlobalFetchTimeouts } from './fetch.js'; -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as dnsPromises from 'node:dns/promises'; import type { LookupAddress, LookupAllOptions } from 'node:dns'; import ipaddr from 'ipaddr.js'; @@ -34,18 +34,14 @@ const { fetchWithTimeout, setGlobalProxy, } = await import('./fetch.js'); - -// Mock global fetch -const originalFetch = global.fetch; -global.fetch = vi.fn(); - interface ErrorWithCode extends Error { code?: string; } describe('fetch utils', () => { beforeEach(() => { - vi.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(global, 'fetch').mockImplementation(vi.fn() as any); // Default DNS lookup to return a public IP, or the IP itself if valid vi.mocked( dnsPromises.lookup as ( @@ -60,8 +56,8 @@ describe('fetch utils', () => { }); }); - afterAll(() => { - global.fetch = originalFetch; + afterEach(() => { + vi.restoreAllMocks(); }); describe('isAddressPrivate', () => { @@ -177,7 +173,7 @@ describe('fetch utils', () => { }); describe('fetchWithTimeout', () => { - it('should handle timeouts', async () => { + it('should throw FetchError with ETIMEDOUT on an internal timeout', async () => { vi.mocked(global.fetch).mockImplementation( (_input, init) => new Promise((_resolve, reject) => { @@ -198,6 +194,46 @@ describe('fetch utils', () => { 'Request timed out after 50ms', ); }); + + it('should throw an AbortError (not ETIMEDOUT) when the caller signal is aborted', async () => { + vi.mocked(global.fetch).mockImplementation( + (_input, init) => + new Promise((_resolve, reject) => { + const rejectWithAbortError = () => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + // @ts-expect-error - for mocking purposes + error.code = 'ABORT_ERR'; + reject(error); + }; + + // Handle the case where the signal is already aborted before + // fetch is called (e.g. controller.abort() called synchronously). + if (init?.signal?.aborted) { + rejectWithAbortError(); + return; + } + + if (init?.signal) { + init.signal.addEventListener('abort', rejectWithAbortError, { + once: true, + }); + } + }), + ); + + const controller = new AbortController(); + // Abort the external signal before the request even starts + controller.abort(); + + const rejection = fetchWithTimeout('http://example.com', 10_000, { + signal: controller.signal, + }); + + await expect(rejection).rejects.toMatchObject({ name: 'AbortError' }); + // Must NOT be classified as a timeout + await expect(rejection).rejects.not.toThrow('timed out'); + }); }); describe('setGlobalProxy', () => { diff --git a/packages/core/src/utils/fetch.ts b/packages/core/src/utils/fetch.ts index 8c2fddc868..ff22df0a34 100644 --- a/packages/core/src/utils/fetch.ts +++ b/packages/core/src/utils/fetch.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { getErrorMessage, isNodeError } from './errors.js'; +import { getErrorMessage, isAbortError } from './errors.js'; import { URL } from 'node:url'; import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici'; import ipaddr from 'ipaddr.js'; @@ -202,7 +202,15 @@ export async function fetchWithTimeout( }); return response; } catch (error) { - if (isNodeError(error) && error.code === 'ABORT_ERR') { + if (isAbortError(error)) { + // If the caller's own signal was already aborted, this is a user-initiated + // cancellation (e.g. Ctrl+C), not an internal timeout. Re-throw as a plain + // AbortError so the retry layer does NOT treat it as a retryable ETIMEDOUT. + if (options?.signal?.aborted) { + // Rethrow the original abort reason or the caught error to preserve + // the stack trace and any custom abort reason (e.g. from Ctrl+C). + throw options.signal.reason ?? error; + } throw new FetchError(`Request timed out after ${timeout}ms`, 'ETIMEDOUT'); } throw new FetchError(getErrorMessage(error), undefined, { cause: error });