mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-18 15:52:53 -07:00
Fix/web fetch ctrl c abort (#24320)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user