Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}


Expand Down
50 changes: 42 additions & 8 deletions src/services/backendAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
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<never> {
let code: string | undefined;
try {
Expand Down Expand Up @@ -201,41 +235,41 @@ class BackendAdapter {
async syncRepositories(repos: Repository[]): Promise<void> {
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 }>;
}

async syncReleases(releases: Release[]): Promise<void> {
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 }>;
}
Expand Down
Loading