diff --git a/src/services/GoogleAPIService.ts b/src/services/GoogleAPIService.ts index f0f197d..011be9d 100644 --- a/src/services/GoogleAPIService.ts +++ b/src/services/GoogleAPIService.ts @@ -6,8 +6,6 @@ import { ProxyAgent } from 'undici'; const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; const CLIENT_SECRET = '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'; -declare const fetch: any; - const URLS = { TOKEN: 'https://oauth2.googleapis.com/token', USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo', @@ -20,6 +18,18 @@ const URLS = { const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64'; const REDIRECT_URI = 'http://localhost:8888/oauth-callback'; +// Request timeout in milliseconds (30 seconds) +const REQUEST_TIMEOUT_MS = 30000; + +/** + * Creates an AbortSignal that times out after the specified duration. + */ +function createTimeoutSignal(ms: number): AbortSignal { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms); + return controller.signal; +} + // --- Types --- export interface TokenResponse { @@ -65,7 +75,7 @@ interface LoadProjectResponse { // --- Service Implementation --- export class GoogleAPIService { - private static getFetchOptions(): any { + private static getFetchOptions() { try { const config = ConfigManager.loadConfig(); if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) { @@ -121,7 +131,17 @@ export class GoogleAPIService { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, + signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), ...this.getFetchOptions(), + }).catch((err: unknown) => { + if (err instanceof Error) { + if (err.name === 'AbortError') { + throw new Error( + 'Token exchange timed out. Please check your network connection and try again.', + ); + } + } + throw err; }); if (!response.ok) { @@ -147,7 +167,17 @@ export class GoogleAPIService { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, + signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), ...this.getFetchOptions(), + }).catch((err: unknown) => { + if (err instanceof Error) { + if (err.name === 'AbortError') { + throw new Error( + 'Token refresh timed out. Please check your network connection and try again.', + ); + } + } + throw err; }); if (!response.ok) { @@ -166,7 +196,17 @@ export class GoogleAPIService { static async getUserInfo(accessToken: string): Promise { const response = await fetch(URLS.USER_INFO, { headers: { Authorization: `Bearer ${accessToken}` }, + signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), ...this.getFetchOptions(), + }).catch((err: unknown) => { + if (err instanceof Error) { + if (err.name === 'AbortError') { + throw new Error( + 'User info request timed out. Please check your network connection and try again.', + ); + } + } + throw err; }); if (!response.ok) { @@ -221,7 +261,7 @@ export class GoogleAPIService { const projectId = await this.fetchProjectId(accessToken); // 2. Build Payload - const payload: Record = {}; + const payload: Record = {}; if (projectId) { payload['project'] = projectId; } @@ -285,11 +325,13 @@ export class GoogleAPIService { } return result; - } catch (e: any) { - console.warn( - `[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`, - ); - lastError = e; + } catch (e: unknown) { + if (e instanceof Error) { + console.warn( + `[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`, + ); + } + lastError = e instanceof Error ? e : new Error(String(e)); if (attempt < maxRetries) { await new Promise((r) => setTimeout(r, 1000)); } diff --git a/src/tests/unit/google-api-timeout.test.ts b/src/tests/unit/google-api-timeout.test.ts new file mode 100644 index 0000000..0ee7ead --- /dev/null +++ b/src/tests/unit/google-api-timeout.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock module to test timeout behavior +describe('GoogleAPIService Timeout', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should abort request after timeout', async () => { + const TIMEOUT_MS = 100; + + // Recreate the same logic used in GoogleAPIService + const createTimeoutSignal = (ms: number): AbortSignal => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms); + return controller.signal; + }; + + const signal = createTimeoutSignal(TIMEOUT_MS); + + // Verify signal is not aborted initially + expect(signal.aborted).toBe(false); + + // Fast-forward time + vi.advanceTimersByTime(TIMEOUT_MS + 10); + + // Signal should now be aborted + expect(signal.aborted).toBe(true); + }); + + it('should set aborted state when controller.abort() is called', () => { + const controller = new AbortController(); + + // Initially not aborted + expect(controller.signal.aborted).toBe(false); + + // Abort the controller + controller.abort(); + + // Signal should now be aborted + expect(controller.signal.aborted).toBe(true); + }); + + it('should transform AbortError to user-friendly message', () => { + // This tests our error transformation logic from GoogleAPIService + const handleAbortError = (err: Error) => { + if (err.name === 'AbortError') { + throw new Error( + 'Token exchange timed out. Please check your network connection and try again.', + ); + } + throw err; + }; + + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + + expect(() => handleAbortError(abortError)).toThrow( + 'Token exchange timed out. Please check your network connection and try again.', + ); + }); + + it('should not transform non-AbortError', () => { + const handleAbortError = (err: Error) => { + if (err.name === 'AbortError') { + throw new Error( + 'Token exchange timed out. Please check your network connection and try again.', + ); + } + throw err; + }; + + const networkError = new Error('Network failure'); + networkError.name = 'NetworkError'; + + expect(() => handleAbortError(networkError)).toThrow('Network failure'); + }); +});