feat(core): use experiment flags for default fetch timeouts (#24261)

This commit is contained in:
Yuna Seol
2026-04-07 18:35:04 -04:00
committed by GitHub
parent 986293bd38
commit 3c5b5db034
10 changed files with 164 additions and 20 deletions
@@ -136,6 +136,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getRetryFetchErrors: vi.fn().mockReturnValue(true), getRetryFetchErrors: vi.fn().mockReturnValue(true),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000), getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
getShellExecutionConfig: vi.fn().mockReturnValue({ getShellExecutionConfig: vi.fn().mockReturnValue({
sandboxManager: new NoopSandboxManager(), sandboxManager: new NoopSandboxManager(),
sanitizationConfig: { sanitizationConfig: {
@@ -19,6 +19,7 @@ export const ExperimentFlags = {
GEMINI_3_1_PRO_LAUNCHED: 45760185, GEMINI_3_1_PRO_LAUNCHED: 45760185,
PRO_MODEL_NO_ACCESS: 45768879, PRO_MODEL_NO_ACCESS: 45768879,
GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641, GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641,
DEFAULT_REQUEST_TIMEOUT: 45773134,
} as const; } as const;
export type ExperimentFlagName = export type ExperimentFlagName =
+62
View File
@@ -644,6 +644,58 @@ describe('Server Config (config.ts)', () => {
}, },
); );
}); });
describe('getRequestTimeoutMs', () => {
it('should return undefined if the flag is not set', () => {
const config = new Config(baseParams);
expect(config.getRequestTimeoutMs()).toBeUndefined();
});
it('should return timeout in milliseconds if flag is set', () => {
const config = new Config({
...baseParams,
experiments: {
flags: {
[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT]: {
intValue: '30',
},
},
experimentIds: [],
},
} as unknown as ConfigParameters);
expect(config.getRequestTimeoutMs()).toBe(30000);
});
it('should return undefined if intValue is not a valid integer', () => {
const config = new Config({
...baseParams,
experiments: {
flags: {
[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT]: {
intValue: 'abc',
},
},
experimentIds: [],
},
} as unknown as ConfigParameters);
expect(config.getRequestTimeoutMs()).toBeUndefined();
});
it('should return undefined if intValue is negative', () => {
const config = new Config({
...baseParams,
experiments: {
flags: {
[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT]: {
intValue: '-10',
},
},
experimentIds: [],
},
} as unknown as ConfigParameters);
expect(config.getRequestTimeoutMs()).toBeUndefined();
});
});
}); });
describe('refreshAuth', () => { describe('refreshAuth', () => {
@@ -2078,8 +2130,18 @@ describe('BaseLlmClient Lifecycle', () => {
usageStatisticsEnabled: false, usageStatisticsEnabled: false,
}; };
it('should throw an error if getBaseLlmClient is called before experiments have been fetched', () => {
const config = new Config(baseParams);
// By default on a new Config instance, experiments are undefined
expect(() => config.getBaseLlmClient()).toThrow(
'BaseLlmClient not initialized. Ensure experiments have been fetched and configuration is ready.',
);
});
it('should throw an error if getBaseLlmClient is called before refreshAuth', () => { it('should throw an error if getBaseLlmClient is called before refreshAuth', () => {
const config = new Config(baseParams); const config = new Config(baseParams);
// Explicitly set experiments to avoid triggering the new missing-experiments error
config.setExperiments({ flags: {}, experimentIds: [] });
expect(() => config.getBaseLlmClient()).toThrow( expect(() => config.getBaseLlmClient()).toThrow(
'BaseLlmClient not initialized. Ensure authentication has occurred and ContentGenerator is ready.', 'BaseLlmClient not initialized. Ensure authentication has occurred and ContentGenerator is ready.',
); );
+32 -7
View File
@@ -160,7 +160,7 @@ import {
} from '../code_assist/experiments/experiments.js'; } from '../code_assist/experiments/experiments.js';
import { AgentRegistry } from '../agents/registry.js'; import { AgentRegistry } from '../agents/registry.js';
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js'; import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
import { setGlobalProxy } from '../utils/fetch.js'; import { setGlobalProxy, updateGlobalFetchTimeouts } from '../utils/fetch.js';
import { SubagentTool } from '../agents/subagent-tool.js'; import { SubagentTool } from '../agents/subagent-tool.js';
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js'; import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
@@ -1548,9 +1548,6 @@ export class Config implements McpContext, AgentLoopContext {
// Only assign to instance properties after successful initialization // Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig; this.contentGeneratorConfig = newContentGeneratorConfig;
// Initialize BaseLlmClient now that the ContentGenerator is available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
const codeAssistServer = getCodeAssistServer(this); const codeAssistServer = getCodeAssistServer(this);
const quotaPromise = codeAssistServer?.projectId const quotaPromise = codeAssistServer?.projectId
? this.refreshUserQuota() ? this.refreshUserQuota()
@@ -1566,6 +1563,17 @@ export class Config implements McpContext, AgentLoopContext {
return undefined; return undefined;
}); });
// Fetch experiments and update timeouts before continuing initialization
const experiments = await this.experimentsPromise;
const requestTimeoutMs = this.getRequestTimeoutMs();
if (requestTimeoutMs !== undefined) {
updateGlobalFetchTimeouts(requestTimeoutMs);
}
// Initialize BaseLlmClient now that the ContentGenerator and experiments are available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
await quotaPromise; await quotaPromise;
const authType = this.contentGeneratorConfig.authType; const authType = this.contentGeneratorConfig.authType;
@@ -1585,9 +1593,6 @@ export class Config implements McpContext, AgentLoopContext {
this.setModel(DEFAULT_GEMINI_MODEL_AUTO); this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
} }
// Fetch admin controls
const experiments = await this.experimentsPromise;
const adminControlsEnabled = const adminControlsEnabled =
experiments?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]?.boolValue ?? experiments?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]?.boolValue ??
false; false;
@@ -1633,6 +1638,11 @@ export class Config implements McpContext, AgentLoopContext {
getBaseLlmClient(): BaseLlmClient { getBaseLlmClient(): BaseLlmClient {
if (!this.baseLlmClient) { if (!this.baseLlmClient) {
// Handle cases where initialization might be deferred or authentication failed // Handle cases where initialization might be deferred or authentication failed
if (!this.experiments) {
throw new Error(
'BaseLlmClient not initialized. Ensure experiments have been fetched and configuration is ready.',
);
}
if (this.contentGenerator) { if (this.contentGenerator) {
this.baseLlmClient = new BaseLlmClient( this.baseLlmClient = new BaseLlmClient(
this.getContentGenerator(), this.getContentGenerator(),
@@ -3153,6 +3163,21 @@ export class Config implements McpContext, AgentLoopContext {
); );
} }
/**
* Returns the configured default request timeout in milliseconds.
*/
getRequestTimeoutMs(): number | undefined {
const flag =
this.experiments?.flags?.[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT];
if (flag?.intValue !== undefined) {
const seconds = parseInt(flag.intValue, 10);
if (Number.isInteger(seconds) && seconds >= 0) {
return seconds * 1000; // Convert seconds to milliseconds
}
}
return undefined;
}
/** /**
* Returns whether Gemini 3.1 Flash Lite has been launched. * Returns whether Gemini 3.1 Flash Lite has been launched.
* *
@@ -102,6 +102,7 @@ describe('BaseLlmClient', () => {
); );
mockConfig = { mockConfig = {
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
getSessionId: vi.fn().mockReturnValue('test-session-id'), getSessionId: vi.fn().mockReturnValue('test-session-id'),
getContentGeneratorConfig: vi getContentGeneratorConfig: vi
.fn() .fn()
+1
View File
@@ -203,6 +203,7 @@ describe('Gemini Client (client.ts)', () => {
authType: AuthType.USE_GEMINI, authType: AuthType.USE_GEMINI,
}; };
mockConfig = { mockConfig = {
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
getContentGeneratorConfig: vi getContentGeneratorConfig: vi
.fn() .fn()
.mockReturnValue(contentGeneratorConfig), .mockReturnValue(contentGeneratorConfig),
@@ -142,6 +142,7 @@ describe('GeminiChat', () => {
let currentActiveModel = 'gemini-pro'; let currentActiveModel = 'gemini-pro';
mockConfig = { mockConfig = {
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
get config() { get config() {
return this; return this;
}, },
@@ -83,6 +83,7 @@ describe('GeminiChat Network Retries', () => {
const testMessageBus = { publish: vi.fn(), subscribe: vi.fn() }; const testMessageBus = { publish: vi.fn(), subscribe: vi.fn() };
mockConfig = { mockConfig = {
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
get config() { get config() {
return this; return this;
}, },
+37 -6
View File
@@ -4,21 +4,37 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { updateGlobalFetchTimeouts } from './fetch.js';
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import {
isPrivateIp,
isPrivateIpAsync,
isAddressPrivate,
fetchWithTimeout,
} from './fetch.js';
import * as dnsPromises from 'node:dns/promises'; import * as dnsPromises from 'node:dns/promises';
import type { LookupAddress, LookupAllOptions } from 'node:dns'; import type { LookupAddress, LookupAllOptions } from 'node:dns';
import ipaddr from 'ipaddr.js'; import ipaddr from 'ipaddr.js';
const { setGlobalDispatcher, Agent, ProxyAgent } = vi.hoisted(() => ({
setGlobalDispatcher: vi.fn(),
Agent: vi.fn(),
ProxyAgent: vi.fn(),
}));
vi.mock('undici', () => ({
setGlobalDispatcher,
Agent,
ProxyAgent,
}));
vi.mock('node:dns/promises', () => ({ vi.mock('node:dns/promises', () => ({
lookup: vi.fn(), lookup: vi.fn(),
})); }));
// Import after mocks are established
const {
isPrivateIp,
isPrivateIpAsync,
isAddressPrivate,
fetchWithTimeout,
setGlobalProxy,
} = await import('./fetch.js');
// Mock global fetch // Mock global fetch
const originalFetch = global.fetch; const originalFetch = global.fetch;
global.fetch = vi.fn(); global.fetch = vi.fn();
@@ -183,4 +199,19 @@ describe('fetch utils', () => {
); );
}); });
}); });
describe('setGlobalProxy', () => {
it('should configure ProxyAgent with experiment flag timeout', () => {
const proxyUrl = 'http://proxy.example.com';
updateGlobalFetchTimeouts(45773134);
setGlobalProxy(proxyUrl);
expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl,
headersTimeout: 45773134,
bodyTimeout: 45773134,
});
expect(setGlobalDispatcher).toHaveBeenCalled();
});
});
}); });
+27 -7
View File
@@ -10,9 +10,6 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
import ipaddr from 'ipaddr.js'; import ipaddr from 'ipaddr.js';
import { lookup } from 'node:dns/promises'; import { lookup } from 'node:dns/promises';
const DEFAULT_HEADERS_TIMEOUT = 300000; // 5 minutes
const DEFAULT_BODY_TIMEOUT = 300000; // 5 minutes
export class FetchError extends Error { export class FetchError extends Error {
constructor( constructor(
message: string, message: string,
@@ -31,14 +28,36 @@ export class PrivateIpError extends Error {
} }
} }
let defaultTimeout = 300000; // 5 minutes
let currentProxy: string | undefined = undefined;
// Configure default global dispatcher with higher timeouts // Configure default global dispatcher with higher timeouts
setGlobalDispatcher( setGlobalDispatcher(
new Agent({ new Agent({
headersTimeout: DEFAULT_HEADERS_TIMEOUT, headersTimeout: defaultTimeout,
bodyTimeout: DEFAULT_BODY_TIMEOUT, bodyTimeout: defaultTimeout,
}), }),
); );
export function updateGlobalFetchTimeouts(timeoutMs: number) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new RangeError(
`Invalid timeout value: ${timeoutMs}. Must be a positive finite number.`,
);
}
defaultTimeout = timeoutMs;
if (currentProxy) {
setGlobalProxy(currentProxy);
} else {
setGlobalDispatcher(
new Agent({
headersTimeout: defaultTimeout,
bodyTimeout: defaultTimeout,
}),
);
}
}
/** /**
* Sanitizes a hostname by stripping IPv6 brackets if present. * Sanitizes a hostname by stripping IPv6 brackets if present.
*/ */
@@ -191,11 +210,12 @@ export async function fetchWithTimeout(
} }
export function setGlobalProxy(proxy: string) { export function setGlobalProxy(proxy: string) {
currentProxy = proxy;
setGlobalDispatcher( setGlobalDispatcher(
new ProxyAgent({ new ProxyAgent({
uri: proxy, uri: proxy,
headersTimeout: DEFAULT_HEADERS_TIMEOUT, headersTimeout: defaultTimeout,
bodyTimeout: DEFAULT_BODY_TIMEOUT, bodyTimeout: defaultTimeout,
}), }),
); );
} }