From 68265db3ce50eec6daf328385ae3a56fe2c966e8 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 31 Jul 2025 15:49:10 +0000 Subject: [PATCH] fix: improve marketplace network error handling for corporate proxies - Enhanced RemoteConfigLoader with better proxy support using axios built-in proxy configuration - Added comprehensive error handling for common network issues (socket hang up, timeouts, DNS issues) - Increased timeout from 10s to 15s for corporate networks - Added User-Agent header and improved retry logic for network errors - Enhanced ClineProvider error messaging with user-friendly notifications - Updated tests to match new configuration parameters Fixes #6488 --- src/core/webview/ClineProvider.ts | 33 +++- .../marketplace/RemoteConfigLoader.ts | 178 +++++++++++++++++- .../__tests__/RemoteConfigLoader.spec.ts | 19 +- 3 files changed, 214 insertions(+), 16 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e013525e06..2f00bb43ff 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1345,19 +1345,44 @@ export class ClineProvider }) } catch (error) { console.error("Failed to fetch marketplace data:", error) + const errorMessage = error instanceof Error ? error.message : String(error) + // Send empty data on error to prevent UI from hanging this.postMessageToWebview({ type: "marketplaceData", organizationMcps: [], marketplaceItems: [], marketplaceInstalledMetadata: { project: {}, global: {} }, - errors: [error instanceof Error ? error.message : String(error)], + errors: [errorMessage], }) - // Show user-friendly error notification for network issues - if (error instanceof Error && error.message.includes("timeout")) { + // Show user-friendly error notification for specific network issues + if (errorMessage.includes("socket hang up") || errorMessage.includes("corporate proxy")) { + vscode.window + .showWarningMessage( + "Marketplace data could not be loaded due to network restrictions or corporate proxy settings. " + + "Core functionality remains available. Please check your network configuration if you need marketplace features.", + "Learn More", + ) + .then((selection) => { + if (selection === "Learn More") { + vscode.env.openExternal( + vscode.Uri.parse("https://docs.roo-code.com/troubleshooting/network-issues"), + ) + } + }) + } else if (errorMessage.includes("timeout")) { vscode.window.showWarningMessage( - "Marketplace data could not be loaded due to network restrictions. Core functionality remains available.", + "Marketplace data could not be loaded due to network timeout. Core functionality remains available.", + ) + } else if (errorMessage.includes("ENOTFOUND") || errorMessage.includes("DNS")) { + vscode.window.showWarningMessage( + "Marketplace data could not be loaded due to DNS resolution issues. Please check your internet connection.", + ) + } else { + // Generic network error + vscode.window.showWarningMessage( + "Marketplace data could not be loaded due to network issues. Core functionality remains available.", ) } } diff --git a/src/services/marketplace/RemoteConfigLoader.ts b/src/services/marketplace/RemoteConfigLoader.ts index fe66b32be0..8908c6f655 100644 --- a/src/services/marketplace/RemoteConfigLoader.ts +++ b/src/services/marketplace/RemoteConfigLoader.ts @@ -1,4 +1,4 @@ -import axios from "axios" +import axios, { AxiosRequestConfig } from "axios" import * as yaml from "yaml" import { z } from "zod" import { getRooCodeApiUrl } from "@roo-code/cloud" @@ -23,6 +23,47 @@ export class RemoteConfigLoader { this.apiBaseUrl = getRooCodeApiUrl() } + private getProxyConfig(): Partial { + // Check for proxy environment variables + 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 + + // Check if the API URL should bypass proxy + if (noProxy) { + const noProxyList = noProxy.split(",").map((host) => host.trim()) + const apiUrl = new URL(this.apiBaseUrl) + const shouldBypassProxy = noProxyList.some((host) => { + if (host === "*") return true + if (host.startsWith(".")) return apiUrl.hostname.endsWith(host) + return apiUrl.hostname === host || apiUrl.hostname.endsWith("." + host) + }) + if (shouldBypassProxy) return {} + } + + // Use axios built-in proxy support + const apiUrl = new URL(this.apiBaseUrl) + const proxyUrl = apiUrl.protocol === "https:" ? httpsProxy : httpProxy + + if (proxyUrl) { + try { + const proxy = new URL(proxyUrl) + return { + proxy: { + protocol: proxy.protocol.slice(0, -1), // Remove trailing ':' + host: proxy.hostname, + port: parseInt(proxy.port) || (proxy.protocol === "https:" ? 443 : 80), + ...(proxy.username && { auth: { username: proxy.username, password: proxy.password || "" } }), + }, + } + } catch (error) { + console.warn("Invalid proxy URL format:", proxyUrl) + } + } + + return {} + } + async loadAllItems(hideMarketplaceMcps = false): Promise { const items: MarketplaceItem[] = [] @@ -80,25 +121,146 @@ export class RemoteConfigLoader { for (let i = 0; i < maxRetries; i++) { try { - const response = await axios.get(url, { - timeout: 10000, // 10 second timeout + const proxyConfig = this.getProxyConfig() + const config: AxiosRequestConfig = { + timeout: 15000, // Increased timeout for corporate networks headers: { Accept: "application/json", "Content-Type": "application/json", + "User-Agent": "Roo-Code-Extension/1.0", }, - }) + // Add proxy configuration + ...proxyConfig, + // Additional network resilience options + maxRedirects: 5, + validateStatus: (status) => status < 500, // Accept 4xx errors but retry 5xx + } + + const response = await axios.get(url, config) + + // Handle non-2xx responses gracefully + if (response.status >= 400) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + return response.data as T } catch (error) { lastError = error as Error - if (i < maxRetries - 1) { - // Exponential backoff: 1s, 2s, 4s - const delay = Math.pow(2, i) * 1000 + + // Enhanced error categorization for better retry logic + const isNetworkError = this.isNetworkError(error) + const isRetryableError = this.isRetryableError(error) + + // Don't retry on non-retryable errors (like 404, 401, etc.) + if (!isRetryableError && i === 0) { + // For non-retryable errors, throw immediately with enhanced message + throw this.enhanceError(error as Error) + } + + if (i < maxRetries - 1 && (isNetworkError || isRetryableError)) { + // Progressive backoff: 2s, 4s, 8s for network issues + const baseDelay = isNetworkError ? 2000 : 1000 + const delay = Math.pow(2, i) * baseDelay await new Promise((resolve) => setTimeout(resolve, delay)) } } } - throw lastError! + throw this.enhanceError(lastError!) + } + + private isNetworkError(error: any): boolean { + if (!error) return false + + const networkErrorCodes = [ + "ECONNRESET", + "ECONNREFUSED", + "ENOTFOUND", + "ENETUNREACH", + "ETIMEDOUT", + "ECONNABORTED", + "EHOSTUNREACH", + "EPIPE", + ] + + return ( + networkErrorCodes.includes(error.code) || + error.message?.includes("socket hang up") || + error.message?.includes("timeout") || + error.message?.includes("network") + ) + } + + private isRetryableError(error: any): boolean { + if (!error) return false + + // Retry on network errors + if (this.isNetworkError(error)) return true + + // Retry on 5xx server errors + if (error.response?.status >= 500) return true + + // Retry on specific axios errors + if (error.code === "ECONNABORTED") return true + + return false + } + + private enhanceError(error: Error): Error { + const originalMessage = error.message || "Unknown error" + + // Provide user-friendly error messages for common network issues + if (originalMessage.includes("socket hang up")) { + return new Error( + "Network connection was interrupted while loading marketplace data. " + + "This may be due to corporate proxy settings or network restrictions. " + + "Please check your network configuration or try again later.", + ) + } + + if (originalMessage.includes("ENOTFOUND") || originalMessage.includes("getaddrinfo")) { + return new Error( + "Unable to resolve marketplace server address. " + + "Please check your internet connection and DNS settings.", + ) + } + + if (originalMessage.includes("ECONNREFUSED")) { + return new Error( + "Connection to marketplace server was refused. " + "The service may be temporarily unavailable.", + ) + } + + if (originalMessage.includes("timeout")) { + return new Error( + "Request to marketplace server timed out. " + + "This may be due to slow network conditions or corporate firewall settings.", + ) + } + + if (originalMessage.includes("ECONNRESET")) { + return new Error( + "Connection to marketplace server was reset. " + + "This often occurs in corporate networks with strict proxy policies.", + ) + } + + // For HTTP errors, provide more context + if (originalMessage.includes("HTTP 4")) { + return new Error( + "Marketplace server returned a client error. " + "The requested resource may not be available.", + ) + } + + if (originalMessage.includes("HTTP 5")) { + return new Error("Marketplace server is experiencing issues. " + "Please try again later.") + } + + // Return enhanced error with original message for debugging + return new Error( + `Failed to load marketplace data: ${originalMessage}. ` + + "If you are behind a corporate firewall, please ensure proxy settings are configured correctly.", + ) } async getItem(id: string, type: MarketplaceItemType): Promise { diff --git a/src/services/marketplace/__tests__/RemoteConfigLoader.spec.ts b/src/services/marketplace/__tests__/RemoteConfigLoader.spec.ts index 61740ab5fb..0374931975 100644 --- a/src/services/marketplace/__tests__/RemoteConfigLoader.spec.ts +++ b/src/services/marketplace/__tests__/RemoteConfigLoader.spec.ts @@ -54,21 +54,27 @@ describe("RemoteConfigLoader", () => { expect(mockedAxios.get).toHaveBeenCalledWith( "https://test.api.com/api/marketplace/modes", expect.objectContaining({ - timeout: 10000, + timeout: 15000, headers: { Accept: "application/json", "Content-Type": "application/json", + "User-Agent": "Roo-Code-Extension/1.0", }, + maxRedirects: 5, + validateStatus: expect.any(Function), }), ) expect(mockedAxios.get).toHaveBeenCalledWith( "https://test.api.com/api/marketplace/mcps", expect.objectContaining({ - timeout: 10000, + timeout: 15000, headers: { Accept: "application/json", "Content-Type": "application/json", + "User-Agent": "Roo-Code-Extension/1.0", }, + maxRedirects: 5, + validateStatus: expect.any(Function), }), ) @@ -140,7 +146,10 @@ describe("RemoteConfigLoader", () => { if (url.includes("/modes")) { modesCallCount++ if (modesCallCount <= 2) { - return Promise.reject(new Error("Network error")) + // Use a network error that will be retried + const error = new Error("ECONNRESET") as any + error.code = "ECONNRESET" + return Promise.reject(error) } return Promise.resolve({ data: mockModesYaml }) } @@ -161,7 +170,9 @@ describe("RemoteConfigLoader", () => { it("should throw error after max retries", async () => { mockedAxios.get.mockRejectedValue(new Error("Persistent network error")) - await expect(loader.loadAllItems()).rejects.toThrow("Persistent network error") + await expect(loader.loadAllItems()).rejects.toThrow( + "Failed to load marketplace data: Persistent network error", + ) // Both endpoints will be called with retries since Promise.all starts both promises // Each endpoint retries 3 times, but due to Promise.all behavior, one might fail faster