diff --git a/nginx.conf b/nginx.conf index dbd565e..9de0c1f 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 dba2406..84e3089 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -76,6 +76,40 @@ class BackendAdapter { clearTimeout(timeoutId); } } + + /** + * 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; + 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' || + // 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('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); + 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!; // 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; try { @@ -201,20 +235,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 +256,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 }>; }