From 0e1344c91030e20d66e74123ef90534d6cfed44c Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 13 May 2026 18:04:08 +0800 Subject: [PATCH 1/2] fix: resolve sync ERR_CONNECTION_RESET with retry logic and timeout tuning - Add fetchWithRetry() with exponential backoff (1s/2s/4s, max 3 retries) - Handles: ERR_CONNECTION_RESET, ERR_CONNECTION_CLOSED, AbortError, Failed to fetch, NetworkError - Increase sync operation timeout from 30s to 120s - Increase nginx proxy_read/send_timeout from 120s to 300s - Add proxy_connect_timeout 30s and proxy_buffering off - Increase client_max_body_size from 50m to 100m Fixes #133 --- nginx.conf | 8 ++++--- src/services/backendAdapter.ts | 43 +++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/nginx.conf b/nginx.conf index dbd565eb..9de0c1f7 100644 --- a/nginx.conf +++ b/nginx.conf @@ -40,9 +40,11 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 120s; - proxy_send_timeout 120s; - client_max_body_size 50m; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + proxy_connect_timeout 30s; + proxy_buffering off; + client_max_body_size 100m; } diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index dba24061..7620d9f7 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -76,6 +76,33 @@ class BackendAdapter { clearTimeout(timeoutId); } } + + /** + * Retry wrapper with exponential backoff for transient network errors + * (ERR_CONNECTION_RESET, timeouts, abort). + */ + private async fetchWithRetry(url: string, options?: RequestInit, timeoutMs = 30000, maxRetries = 3): Promise { + let lastError: Error | undefined; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await this.fetchWithTimeout(url, options, timeoutMs); + } catch (err) { + lastError = err as Error; + const isRetryable = + lastError.name === 'AbortError' || + lastError.message?.includes('ERR_CONNECTION_RESET') || + lastError.message?.includes('ERR_CONNECTION_CLOSED') || + lastError.message?.includes('Failed to fetch') || + lastError.message?.includes('NetworkError'); + if (!isRetryable || attempt === maxRetries) throw lastError; + // Exponential backoff: 1s, 2s, 4s + const delay = Math.min(1000 * Math.pow(2, attempt), 4000); + console.warn(`⚠️ Sync request failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms...`, lastError.message); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + throw lastError!; + } private async throwTranslatedError(res: Response, fallbackPrefix: string): Promise { let code: string | undefined; try { @@ -201,20 +228,20 @@ class BackendAdapter { async syncRepositories(repos: Repository[]): Promise { if (!this._backendUrl) return; - const res = await this.fetchWithTimeout(`${this._backendUrl}/repositories`, { + const res = await this.fetchWithRetry(`${this._backendUrl}/repositories`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify({ repositories: repos, isFullSync: true }) - }); + }, 120000, 3); if (!res.ok) await this.throwTranslatedError(res, 'Sync repositories error'); } async fetchRepositories(): Promise<{ repositories: Repository[]; total: number }> { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await this.fetchWithTimeout(`${this._backendUrl}/repositories?limit=10000`, { + const res = await this.fetchWithRetry(`${this._backendUrl}/repositories?limit=10000`, { headers: this.getAuthHeaders() - }); + }, 120000, 3); if (!res.ok) await this.throwTranslatedError(res, 'Fetch error'); return res.json() as Promise<{ repositories: Repository[]; total: number }>; } @@ -222,20 +249,20 @@ class BackendAdapter { async syncReleases(releases: Release[]): Promise { if (!this._backendUrl) return; - const res = await this.fetchWithTimeout(`${this._backendUrl}/releases`, { + const res = await this.fetchWithRetry(`${this._backendUrl}/releases`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify({ releases }) - }); + }, 120000, 3); if (!res.ok) await this.throwTranslatedError(res, 'Sync releases error'); } async fetchReleases(): Promise<{ releases: Release[]; total: number }> { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await this.fetchWithTimeout(`${this._backendUrl}/releases?limit=10000`, { + const res = await this.fetchWithRetry(`${this._backendUrl}/releases?limit=10000`, { headers: this.getAuthHeaders() - }); + }, 120000, 3); if (!res.ok) await this.throwTranslatedError(res, 'Fetch error'); return res.json() as Promise<{ releases: Release[]; total: number }>; } From a7c08a7cb0d44ce8312e9b4078c5af8460f70d1d Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 13 May 2026 18:16:56 +0800 Subject: [PATCH 2/2] fix: improve retry error detection for browser + Node.js undici - Remove ERR_CONNECTION_RESET/ERR_CONNECTION_CLOSED from message checks (these codes are in error.cause.code in Node.js, never in message) - Add Safari 'Load failed' message check - Add Node.js undici cause.code checks: ECONNRESET, ECONNREFUSED, UND_ERR_SOCKET, UND_ERR_CONNECT_TIMEOUT, UND_ERR_HEADERS_TIMEOUT - Add eslint-disable for unreachable throw (TS control flow limitation) Addresses CodeRabbit review comments --- src/services/backendAdapter.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 7620d9f7..84e30892 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -78,8 +78,8 @@ class BackendAdapter { } /** - * Retry wrapper with exponential backoff for transient network errors - * (ERR_CONNECTION_RESET, timeouts, abort). + * Retry wrapper with exponential backoff for transient network errors. + * Covers browser fetch (Chrome/Firefox/Safari) and Node.js undici fetch. */ private async fetchWithRetry(url: string, options?: RequestInit, timeoutMs = 30000, maxRetries = 3): Promise { let lastError: Error | undefined; @@ -90,10 +90,17 @@ class BackendAdapter { lastError = err as Error; const isRetryable = lastError.name === 'AbortError' || - lastError.message?.includes('ERR_CONNECTION_RESET') || - lastError.message?.includes('ERR_CONNECTION_CLOSED') || + // Browser messages: Chrome/Edge "Failed to fetch", Firefox "NetworkError...", Safari "Load failed" lastError.message?.includes('Failed to fetch') || - lastError.message?.includes('NetworkError'); + lastError.message?.includes('NetworkError') || + lastError.message?.includes('Load failed') || + // Node.js undici: message is "fetch failed", real code is in error.cause + lastError.message === 'fetch failed' || + (lastError as { cause?: { code?: string } }).cause?.code === 'ECONNRESET' || + (lastError as { cause?: { code?: string } }).cause?.code === 'ECONNREFUSED' || + (lastError as { cause?: { code?: string } }).cause?.code === 'UND_ERR_SOCKET' || + (lastError as { cause?: { code?: string } }).cause?.code === 'UND_ERR_CONNECT_TIMEOUT' || + (lastError as { cause?: { code?: string } }).cause?.code === 'UND_ERR_HEADERS_TIMEOUT'; if (!isRetryable || attempt === maxRetries) throw lastError; // Exponential backoff: 1s, 2s, 4s const delay = Math.min(1000 * Math.pow(2, attempt), 4000); @@ -101,7 +108,7 @@ class BackendAdapter { await new Promise(resolve => setTimeout(resolve, delay)); } } - throw lastError!; + throw lastError!; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion -- TypeScript control flow can't prove this is unreachable } private async throwTranslatedError(res: Response, fallbackPrefix: string): Promise { let code: string | undefined;