From 3879fd433472efbf69d27491be4401f601ee570c Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 3 Aug 2025 14:51:19 +0000 Subject: [PATCH] fix: improve CloudSettingsService error handling for fetch failures - Add retry mechanism with exponential backoff (max 3 retries) - Add detailed network diagnostics for fetch failures - Log proxy configuration, Node.js version, and VSCode version - Provide helpful error messages for common network issues - Add comprehensive tests for retry logic and error handling This should help users debug "fetch failed" errors when using Gemini embedder for codebase indexing by providing more context about the failure and attempting automatic retries. Fixes #6626 --- packages/cloud/src/CloudSettingsService.ts | 104 +++++++++++++++- .../__tests__/CloudSettingsService.test.ts | 111 +++++++++++++++++- 2 files changed, 208 insertions(+), 7 deletions(-) diff --git a/packages/cloud/src/CloudSettingsService.ts b/packages/cloud/src/CloudSettingsService.ts index c842d800fc..c6a4574a17 100644 --- a/packages/cloud/src/CloudSettingsService.ts +++ b/packages/cloud/src/CloudSettingsService.ts @@ -14,6 +14,8 @@ import { RefreshTimer } from "./RefreshTimer" import type { SettingsService } from "./SettingsService" const ORGANIZATION_SETTINGS_CACHE_KEY = "organization-settings" +const MAX_FETCH_RETRIES = 3 +const INITIAL_RETRY_DELAY = 1000 // 1 second export interface SettingsServiceEvents { "settings-updated": [ @@ -73,6 +75,67 @@ export class CloudSettingsService extends EventEmitter im } } + /** + * Performs network diagnostics to help debug connectivity issues + */ + private async performNetworkDiagnostics(url: string): Promise { + this.log("[cloud-settings] Performing network diagnostics...") + + // Check if we're in a proxy environment + const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy + const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy + const noProxy = process.env.NO_PROXY || process.env.no_proxy + + if (httpProxy || httpsProxy) { + this.log(" Proxy configuration detected:") + if (httpProxy) this.log(` HTTP_PROXY: ${httpProxy}`) + if (httpsProxy) this.log(` HTTPS_PROXY: ${httpsProxy}`) + if (noProxy) this.log(` NO_PROXY: ${noProxy}`) + } + + // Log Node.js version (can affect fetch behavior) + this.log(` Node.js version: ${process.version}`) + + // Log VSCode version + this.log(` VSCode version: ${vscode.version}`) + + // Try to parse the URL to check components + try { + const parsedUrl = new URL(url) + this.log(` URL components:`) + this.log(` Protocol: ${parsedUrl.protocol}`) + this.log(` Hostname: ${parsedUrl.hostname}`) + this.log(` Port: ${parsedUrl.port || "(default)"}`) + this.log(` Path: ${parsedUrl.pathname}`) + } catch (e) { + this.log(` Failed to parse URL: ${e}`) + } + } + + /** + * Attempts to fetch with retry logic and enhanced error handling + */ + private async fetchWithRetry(url: string, options: RequestInit, retryCount: number = 0): Promise { + try { + const response = await fetch(url, options) + return response + } catch (error) { + if (retryCount >= MAX_FETCH_RETRIES) { + throw error + } + + const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount) + this.log( + `[cloud-settings] Fetch failed, retrying in ${delay}ms (attempt ${retryCount + 1}/${MAX_FETCH_RETRIES})`, + ) + + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, delay)) + + return this.fetchWithRetry(url, options, retryCount + 1) + } + } + private async fetchSettings(): Promise { const token = this.authService.getSessionToken() @@ -80,8 +143,13 @@ export class CloudSettingsService extends EventEmitter im return false } + const apiUrl = getRooCodeApiUrl() + const fullUrl = `${apiUrl}/api/organization-settings` + try { - const response = await fetch(`${getRooCodeApiUrl()}/api/organization-settings`, { + this.log(`[cloud-settings] Attempting to fetch from: ${fullUrl}`) + + const response = await this.fetchWithRetry(fullUrl, { headers: { Authorization: `Bearer ${token}`, }, @@ -119,7 +187,39 @@ export class CloudSettingsService extends EventEmitter im return true } catch (error) { - this.log("[cloud-settings] Error fetching organization settings:", error) + // Enhanced error logging with more details + if (error instanceof Error) { + this.log("[cloud-settings] Error fetching organization settings:") + this.log(" Error name:", error.name) + this.log(" Error message:", error.message) + + // Check for specific error types + if (error.message.includes("fetch failed")) { + this.log(" This appears to be a network connectivity issue.") + this.log(" Possible causes:") + this.log(" - Network proxy configuration") + this.log(" - Firewall blocking the request") + this.log(" - DNS resolution issues") + this.log(" - VSCode extension host network restrictions") + this.log(` Target URL: ${fullUrl}`) + + // Perform additional network diagnostics + await this.performNetworkDiagnostics(fullUrl) + + // Log additional error details if available + if ("cause" in error && error.cause) { + this.log(" Underlying cause:", error.cause) + } + } + + // Log stack trace for debugging + if (error.stack) { + this.log(" Stack trace:", error.stack) + } + } else { + this.log("[cloud-settings] Unknown error type:", error) + } + return false } } diff --git a/packages/cloud/src/__tests__/CloudSettingsService.test.ts b/packages/cloud/src/__tests__/CloudSettingsService.test.ts index 4a85383ba4..5151f0a4eb 100644 --- a/packages/cloud/src/__tests__/CloudSettingsService.test.ts +++ b/packages/cloud/src/__tests__/CloudSettingsService.test.ts @@ -347,18 +347,119 @@ describe("CloudSettingsService", () => { }) it("should handle fetch errors gracefully", async () => { + vi.useFakeTimers() mockAuthService.getSessionToken.mockReturnValue("valid-token") + + // Mock fetch to always fail vi.mocked(fetch).mockRejectedValue(new Error("Network error")) // Get the callback function passed to RefreshTimer const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback - const result = await timerCallback() + const resultPromise = timerCallback() + + // Advance through all retries + await vi.advanceTimersByTimeAsync(1000) // First retry + await vi.advanceTimersByTimeAsync(2000) // Second retry + await vi.advanceTimersByTimeAsync(4000) // Third retry + + const result = await resultPromise expect(result).toBe(false) - expect(mockLog).toHaveBeenCalledWith( - "[cloud-settings] Error fetching organization settings:", - expect.any(Error), - ) + expect(mockLog).toHaveBeenCalledWith("[cloud-settings] Error fetching organization settings:") + expect(mockLog).toHaveBeenCalledWith(" Error name:", "Error") + expect(mockLog).toHaveBeenCalledWith(" Error message:", "Network error") + + vi.useRealTimers() + }) + + it("should retry on fetch failure with exponential backoff", async () => { + vi.useFakeTimers() + mockAuthService.getSessionToken.mockReturnValue("valid-token") + + // Mock fetch to fail twice then succeed + vi.mocked(fetch) + .mockRejectedValueOnce(new Error("fetch failed")) + .mockRejectedValueOnce(new Error("fetch failed")) + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue(mockSettings), + } as unknown as Response) + + // Get the callback function passed to RefreshTimer + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + const resultPromise = timerCallback() + + // First retry after 1 second + await vi.advanceTimersByTimeAsync(1000) + + // Second retry after 2 seconds (exponential backoff) + await vi.advanceTimersByTimeAsync(2000) + + const result = await resultPromise + + expect(result).toBe(true) + expect(fetch).toHaveBeenCalledTimes(3) + expect(mockLog).toHaveBeenCalledWith("[cloud-settings] Fetch failed, retrying in 1000ms (attempt 1/3)") + expect(mockLog).toHaveBeenCalledWith("[cloud-settings] Fetch failed, retrying in 2000ms (attempt 2/3)") + + vi.useRealTimers() + }) + + it("should fail after max retries", async () => { + vi.useFakeTimers() + mockAuthService.getSessionToken.mockReturnValue("valid-token") + + // Mock fetch to always fail + vi.mocked(fetch).mockRejectedValue(new Error("fetch failed")) + + // Get the callback function passed to RefreshTimer + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + const resultPromise = timerCallback() + + // Advance through all retries + await vi.advanceTimersByTimeAsync(1000) // First retry + await vi.advanceTimersByTimeAsync(2000) // Second retry + await vi.advanceTimersByTimeAsync(4000) // Third retry + + const result = await resultPromise + + expect(result).toBe(false) + expect(fetch).toHaveBeenCalledTimes(4) // Initial + 3 retries + expect(mockLog).toHaveBeenCalledWith(" This appears to be a network connectivity issue.") + + vi.useRealTimers() + }) + + it("should perform network diagnostics on fetch failed error", async () => { + vi.useFakeTimers() + mockAuthService.getSessionToken.mockReturnValue("valid-token") + const fetchError = new Error("fetch failed") + vi.mocked(fetch).mockRejectedValue(fetchError) + + // Mock environment variables + process.env.HTTPS_PROXY = "http://proxy.example.com:8080" + + // Get the callback function passed to RefreshTimer + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + const resultPromise = timerCallback() + + // Advance through all retries + await vi.advanceTimersByTimeAsync(1000) // First retry + await vi.advanceTimersByTimeAsync(2000) // Second retry + await vi.advanceTimersByTimeAsync(4000) // Third retry + + const result = await resultPromise + + expect(result).toBe(false) + expect(mockLog).toHaveBeenCalledWith("[cloud-settings] Performing network diagnostics...") + expect(mockLog).toHaveBeenCalledWith(" Proxy configuration detected:") + expect(mockLog).toHaveBeenCalledWith(" HTTPS_PROXY: http://proxy.example.com:8080") + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining(" Node.js version:")) + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining(" VSCode version:")) + + // Clean up + delete process.env.HTTPS_PROXY + vi.useRealTimers() }) it("should handle invalid response format", async () => {