mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
refactor(cliproxy): enhance binary downloader with robust error handling
- Add categorized error detection for socket, timeout, HTTP, and redirect errors - Implement smarter exponential backoff (longer delays for socket errors) - Increase max retries from 3 to 5 with dynamic timeout adjustment - Add resource cleanup (disable connection pooling for clean process exit) - Add user-friendly error messages for each error type - Refactor fetchText/fetchJson with retry logic and proper error handling - Set 120s timeout for large binary downloads, 15-30s for text/JSON - Prevent double-resolution and race conditions in Promise handlers
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Binary Downloader
|
||||
* Handles downloading files with retry logic, progress tracking, and redirect following.
|
||||
* Robust handling for transient network errors (socket hang up, ECONNRESET, etc.)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
@@ -14,40 +15,111 @@ export interface DownloaderConfig {
|
||||
maxRetries: number;
|
||||
/** Enable verbose logging */
|
||||
verbose: boolean;
|
||||
/** Timeout in milliseconds (default: 120000 for large files) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: DownloaderConfig = {
|
||||
maxRetries: 3,
|
||||
maxRetries: 5,
|
||||
verbose: false,
|
||||
timeout: 120000, // 2 minutes for large binaries
|
||||
};
|
||||
|
||||
/** Error types for categorized handling */
|
||||
export type NetworkErrorType = 'socket' | 'timeout' | 'http' | 'redirect' | 'unknown';
|
||||
|
||||
/** Categorize error for appropriate retry/reporting */
|
||||
export function categorizeError(error: Error): NetworkErrorType {
|
||||
const msg = error.message.toLowerCase();
|
||||
if (msg.includes('socket hang up') || msg.includes('econnreset') || msg.includes('epipe')) {
|
||||
return 'socket';
|
||||
}
|
||||
if (msg.includes('timeout') || msg.includes('etimedout')) {
|
||||
return 'timeout';
|
||||
}
|
||||
if (msg.includes('http ')) {
|
||||
return 'http';
|
||||
}
|
||||
if (msg.includes('redirect')) {
|
||||
return 'redirect';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Get user-friendly error message */
|
||||
export function getErrorMessage(error: Error, attempt: number, maxAttempts: number): string {
|
||||
const type = categorizeError(error);
|
||||
const prefix = `[Attempt ${attempt}/${maxAttempts}]`;
|
||||
|
||||
switch (type) {
|
||||
case 'socket':
|
||||
return `${prefix} Connection dropped (socket hang up) - retrying with fresh connection...`;
|
||||
case 'timeout':
|
||||
return `${prefix} Download timed out - retrying with extended timeout...`;
|
||||
case 'http':
|
||||
return `${prefix} Server error: ${error.message}`;
|
||||
case 'redirect':
|
||||
return `${prefix} Redirect failed: ${error.message}`;
|
||||
default:
|
||||
return `${prefix} Network error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if error is retryable */
|
||||
export function isRetryableError(error: Error): boolean {
|
||||
const type = categorizeError(error);
|
||||
// Retry socket errors, timeouts, and unknown errors
|
||||
if (type === 'socket' || type === 'timeout' || type === 'unknown') {
|
||||
return true;
|
||||
}
|
||||
if (type === 'http') {
|
||||
const msg = error.message;
|
||||
// Retry 5xx server errors and 429 rate limit (HTTP 5xx matches 500, 502, 503, 504, etc.)
|
||||
return msg.includes('HTTP 5') || msg.includes('HTTP 429');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file from URL with progress tracking
|
||||
* @param timeout Timeout in ms (default 120000 for large files)
|
||||
*/
|
||||
export function downloadFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
onProgress?: ProgressCallback,
|
||||
verbose = false
|
||||
verbose = false,
|
||||
timeout = 120000
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = (err?: Error) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
fs.unlink(destPath, () => {}); // Cleanup partial file
|
||||
reject(err || new Error('Download aborted'));
|
||||
};
|
||||
|
||||
const handleResponse = (res: http.IncomingMessage) => {
|
||||
// Handle redirects (GitHub releases use 302)
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
const redirectUrl = res.headers.location;
|
||||
if (!redirectUrl) {
|
||||
reject(new Error('Redirect without location header'));
|
||||
cleanup(new Error('Redirect without location header'));
|
||||
return;
|
||||
}
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] Following redirect: ${redirectUrl}`);
|
||||
}
|
||||
downloadFile(redirectUrl, destPath, onProgress, verbose).then(resolve).catch(reject);
|
||||
downloadFile(redirectUrl, destPath, onProgress, verbose, timeout)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
||||
cleanup(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,58 +142,94 @@ export function downloadFile(
|
||||
res.pipe(fileStream);
|
||||
|
||||
fileStream.on('finish', () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
fileStream.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
fileStream.on('error', (err) => {
|
||||
fs.unlink(destPath, () => {}); // Cleanup partial file
|
||||
reject(err);
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
fs.unlink(destPath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
fileStream.on('error', cleanup);
|
||||
res.on('error', cleanup);
|
||||
};
|
||||
|
||||
const protocol = url.startsWith('https') ? https : http;
|
||||
const req = protocol.get(url, handleResponse);
|
||||
|
||||
req.on('error', reject);
|
||||
req.setTimeout(60000, () => {
|
||||
// Use agent: false to prevent connection pooling (allows process to exit)
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'CCS-CLIProxyPlus-Downloader/1.0',
|
||||
},
|
||||
agent: false, // Disable connection pooling for clean exit
|
||||
};
|
||||
|
||||
const req = protocol.get(url, options, handleResponse);
|
||||
|
||||
req.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
cleanup(err);
|
||||
}
|
||||
});
|
||||
|
||||
req.setTimeout(timeout, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Download timeout (60s)'));
|
||||
if (!resolved) {
|
||||
cleanup(new Error(`Download timeout (${timeout / 1000}s)`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file with retry logic and exponential backoff
|
||||
* Uses smarter backoff for socket errors (longer delays)
|
||||
*/
|
||||
export async function downloadWithRetry(
|
||||
url: string,
|
||||
destPath: string,
|
||||
config: Partial<DownloaderConfig> = {}
|
||||
): Promise<DownloadResult> {
|
||||
const { maxRetries, verbose } = { ...DEFAULT_CONFIG, ...config };
|
||||
let lastError = '';
|
||||
const { maxRetries, verbose, timeout } = { ...DEFAULT_CONFIG, ...config };
|
||||
let lastError: Error | null = null;
|
||||
let retries = 0;
|
||||
let currentTimeout = timeout || 120000;
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
await downloadFile(url, destPath, undefined, verbose);
|
||||
await downloadFile(url, destPath, undefined, verbose, currentTimeout);
|
||||
return { success: true, filePath: destPath, retries };
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
lastError = err.message;
|
||||
lastError = err;
|
||||
retries++;
|
||||
|
||||
if (retries < maxRetries) {
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
const delay = Math.pow(2, retries - 1) * 1000;
|
||||
// Check if error is retryable
|
||||
if (!isRetryableError(err)) {
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] Retry ${retries}/${maxRetries} after ${delay}ms: ${lastError}`);
|
||||
console.error(`[cliproxy] Non-retryable error: ${err.message}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (retries < maxRetries) {
|
||||
const errorType = categorizeError(err);
|
||||
// Socket errors: longer backoff (2s, 4s, 8s, 16s, 32s)
|
||||
// Timeout errors: increase timeout and use standard backoff
|
||||
// Other errors: standard exponential backoff (1s, 2s, 4s...)
|
||||
let delay: number;
|
||||
|
||||
if (errorType === 'socket') {
|
||||
delay = Math.pow(2, retries) * 1000; // 2s, 4s, 8s, 16s, 32s
|
||||
} else if (errorType === 'timeout') {
|
||||
delay = Math.pow(2, retries - 1) * 1000;
|
||||
currentTimeout = Math.min(currentTimeout * 1.5, 300000); // Increase timeout up to 5 min
|
||||
} else {
|
||||
delay = Math.pow(2, retries - 1) * 1000;
|
||||
}
|
||||
|
||||
// Log with user-friendly message
|
||||
console.error(`[cliproxy] ${getErrorMessage(err, retries, maxRetries)}`);
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] Waiting ${delay}ms before retry...`);
|
||||
}
|
||||
await sleep(delay);
|
||||
}
|
||||
@@ -130,16 +238,18 @@ export async function downloadWithRetry(
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Download failed after ${retries} attempts: ${lastError}`,
|
||||
error: `Download failed after ${retries} attempts: ${lastError?.message || 'Unknown error'}`,
|
||||
retries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch text content from URL
|
||||
* Fetch text content from URL (single attempt)
|
||||
*/
|
||||
export function fetchText(url: string, verbose = false): Promise<string> {
|
||||
function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
|
||||
const handleResponse = (res: http.IncomingMessage) => {
|
||||
// Handle redirects
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
@@ -148,7 +258,7 @@ export function fetchText(url: string, verbose = false): Promise<string> {
|
||||
reject(new Error('Redirect without location header'));
|
||||
return;
|
||||
}
|
||||
fetchText(redirectUrl, verbose).then(resolve).catch(reject);
|
||||
fetchTextOnce(redirectUrl, verbose, timeout).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,30 +269,90 @@ export function fetchText(url: string, verbose = false): Promise<string> {
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => resolve(data));
|
||||
res.on('error', reject);
|
||||
res.on('end', () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
res.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const protocol = url.startsWith('https') ? https : http;
|
||||
const req = protocol.get(url, handleResponse);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(30000, () => {
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'CCS-CLIProxyPlus-Downloader/1.0',
|
||||
},
|
||||
agent: false, // Disable connection pooling for clean exit
|
||||
};
|
||||
|
||||
const req = protocol.get(url, options, handleResponse);
|
||||
req.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.setTimeout(timeout, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout (30s)'));
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`Request timeout (${timeout / 1000}s)`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from URL (for GitHub API)
|
||||
* Fetch text content from URL with retry logic
|
||||
*/
|
||||
export function fetchJson(url: string, verbose = false): Promise<Record<string, unknown>> {
|
||||
export async function fetchText(url: string, verbose = false, maxRetries = 3): Promise<string> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fetchTextOnce(url, verbose);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
lastError = err;
|
||||
|
||||
if (!isRetryableError(err) || attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = Math.pow(2, attempt - 1) * 1000;
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] fetchText retry ${attempt}/${maxRetries}: ${err.message}`);
|
||||
}
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('fetchText failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from URL (single attempt, for GitHub API)
|
||||
*/
|
||||
function fetchJsonOnce(
|
||||
url: string,
|
||||
verbose = false,
|
||||
timeout = 15000
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
let resolved = false;
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
headers: {
|
||||
'User-Agent': 'CCS-CLIProxyPlus-Updater/1.0',
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
agent: false, // Disable connection pooling for clean exit
|
||||
};
|
||||
|
||||
const handleResponse = (res: http.IncomingMessage) => {
|
||||
@@ -192,7 +362,7 @@ export function fetchJson(url: string, verbose = false): Promise<Record<string,
|
||||
reject(new Error('Redirect without location header'));
|
||||
return;
|
||||
}
|
||||
fetchJson(redirectUrl, verbose).then(resolve).catch(reject);
|
||||
fetchJsonOnce(redirectUrl, verbose, timeout).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,24 +374,71 @@ export function fetchJson(url: string, verbose = false): Promise<Record<string,
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error('Invalid JSON from GitHub API'));
|
||||
}
|
||||
});
|
||||
res.on('error', reject);
|
||||
res.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const req = https.get(url, options, handleResponse);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => {
|
||||
req.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.setTimeout(timeout, () => {
|
||||
req.destroy();
|
||||
reject(new Error('GitHub API timeout (10s)'));
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`GitHub API timeout (${timeout / 1000}s)`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from URL (for GitHub API) with retry logic
|
||||
*/
|
||||
export async function fetchJson(
|
||||
url: string,
|
||||
verbose = false,
|
||||
maxRetries = 3
|
||||
): Promise<Record<string, unknown>> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fetchJsonOnce(url, verbose);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
lastError = err;
|
||||
|
||||
if (!isRetryableError(err) || attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = Math.pow(2, attempt - 1) * 1000;
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] GitHub API retry ${attempt}/${maxRetries}: ${err.message}`);
|
||||
}
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('fetchJson failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep helper
|
||||
*/
|
||||
|
||||
@@ -424,13 +424,17 @@ export async function execClaudeWithCLIProxy(
|
||||
if (proxyStatus.running && proxyStatus.verified) {
|
||||
// Healthy proxy found - join it
|
||||
if (proxyStatus.pid) {
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined;
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined;
|
||||
}
|
||||
if (sessionId) {
|
||||
console.log(info(`Joined existing CLIProxy on port ${cfg.port} (${proxyStatus.method})`));
|
||||
} else {
|
||||
// Failed to register session, but proxy is running - continue anyway
|
||||
log(`Failed to register session but proxy is healthy, continuing...`);
|
||||
// Failed to register session - proxy is running but we can't track it
|
||||
// This happens when port-process detection fails (permissions, platform issues)
|
||||
console.log(
|
||||
info(`Using existing CLIProxy on port ${cfg.port} (session tracking unavailable)`)
|
||||
);
|
||||
log(`PID=${proxyStatus.pid ?? 'unknown'}, session registration skipped`);
|
||||
}
|
||||
return; // Exit lock early, skip spawning
|
||||
}
|
||||
@@ -441,7 +445,7 @@ export async function execClaudeWithCLIProxy(
|
||||
const becameHealthy = await waitForProxyHealthy(cfg.port, cfg.timeout);
|
||||
if (becameHealthy) {
|
||||
if (proxyStatus.pid) {
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined;
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined;
|
||||
}
|
||||
console.log(info(`Joined CLIProxy after startup wait`));
|
||||
return; // Exit lock early
|
||||
@@ -459,7 +463,7 @@ export async function execClaudeWithCLIProxy(
|
||||
// Last resort: try HTTP health check (handles Windows PID-XXXXX case)
|
||||
const isActuallyOurs = await waitForProxyHealthy(cfg.port, 1000);
|
||||
if (isActuallyOurs) {
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid) ?? undefined;
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid, verbose) ?? undefined;
|
||||
console.log(info(`Reclaimed CLIProxy with unrecognized process name`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,12 +73,25 @@ export async function detectRunningProxy(
|
||||
// Proxy is running and responsive
|
||||
// Try to get PID from session lock if available
|
||||
const lock = getExistingProxy(port);
|
||||
log(`HTTP check passed, proxy healthy (PID: ${lock?.pid ?? 'unknown'})`);
|
||||
let pid = lock?.pid;
|
||||
|
||||
// If no PID from session lock, try port-process detection
|
||||
// This handles orphaned proxies (running but no sessions.json)
|
||||
if (!pid) {
|
||||
log('No PID from session lock, checking port process...');
|
||||
const portProcess = await getPortProcess(port);
|
||||
if (portProcess) {
|
||||
pid = portProcess.pid;
|
||||
log(`Got PID from port process: ${pid}`);
|
||||
}
|
||||
}
|
||||
|
||||
log(`HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'})`);
|
||||
return {
|
||||
running: true,
|
||||
verified: true,
|
||||
method: 'http',
|
||||
pid: lock?.pid,
|
||||
pid,
|
||||
sessionCount: lock?.sessions?.length,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user