diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index 97582ef5..dbab8b3a 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -10,43 +10,9 @@ import { initializeToken } from '@/ui/tokenInit' import { spawnHappyCLI } from '@/utils/spawnHappyCLI' import { maybeAutoStartServer } from '@/utils/autoStartServer' import { withBunRuntimeEnv } from '@/utils/bunRuntime' +import { extractErrorInfo } from '@/utils/errorUtils' import type { CommandDefinition } from './types' -function extractErrorInfo(error: unknown): { - message: string - messageLower: string - axiosCode?: string - httpStatus?: number - responseErrorText: string -} { - const message = error instanceof Error ? error.message : 'Unknown error' - const messageLower = message.toLowerCase() - - if (typeof error !== 'object' || error === null) { - return { message, messageLower, responseErrorText: '' } - } - - const record = error as Record - const axiosCode = typeof record.code === 'string' ? record.code : undefined - const response = typeof record.response === 'object' && record.response !== null - ? (record.response as Record) - : undefined - const httpStatus = typeof response?.status === 'number' ? response.status : undefined - const responseData = response?.data - const responseError = typeof responseData === 'object' && responseData !== null - ? (responseData as Record).error - : undefined - const responseErrorText = typeof responseError === 'string' ? responseError : '' - - return { - message, - messageLower, - axiosCode, - httpStatus, - responseErrorText - } -} - export const claudeCommand: CommandDefinition = { name: 'default', requiresRuntimeAssets: true, diff --git a/cli/src/daemon/run.ts b/cli/src/daemon/run.ts index b6d5c91f..7f4f45bd 100644 --- a/cli/src/daemon/run.ts +++ b/cli/src/daemon/run.ts @@ -12,6 +12,8 @@ import { getEnvironmentInfo } from '@/ui/doctor'; import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquireDaemonLock, releaseDaemonLock } from '@/persistence'; import { isProcessAlive, isWindows, killProcess, killProcessByChildProcess } from '@/utils/process'; +import { withRetry } from '@/utils/time'; +import { isRetryableConnectionError } from '@/utils/errorUtils'; import { cleanupDaemonState, getInstalledCliMtimeMs, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient'; import { startDaemonControlServer } from './controlServer'; @@ -522,12 +524,24 @@ export async function startDaemon(): Promise { // Create API client const api = await ApiClient.create(); - // Get or create machine - const machine = await api.getOrCreateMachine({ - machineId, - metadata: buildMachineMetadata(), - daemonState: initialDaemonState - }); + // Get or create machine (with retry for transient connection errors) + const machine = await withRetry( + () => api.getOrCreateMachine({ + machineId, + metadata: buildMachineMetadata(), + daemonState: initialDaemonState + }), + { + maxAttempts: 60, + minDelay: 1000, + maxDelay: 30000, + shouldRetry: isRetryableConnectionError, + onRetry: (error, attempt, nextDelayMs) => { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.debug(`[DAEMON RUN] Failed to register machine (attempt ${attempt}), retrying in ${nextDelayMs}ms: ${errorMsg}`) + } + } + ); logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`); // Create realtime machine session diff --git a/cli/src/utils/errorUtils.ts b/cli/src/utils/errorUtils.ts new file mode 100644 index 00000000..c30a0548 --- /dev/null +++ b/cli/src/utils/errorUtils.ts @@ -0,0 +1,81 @@ +/** + * Error handling utilities for API requests + */ + +export type ErrorInfo = { + message: string + messageLower: string + axiosCode?: string + httpStatus?: number + responseErrorText: string +} + +/** + * Extract structured error information from an unknown error + */ +export function extractErrorInfo(error: unknown): ErrorInfo { + const message = error instanceof Error ? error.message : 'Unknown error' + const messageLower = message.toLowerCase() + + if (typeof error !== 'object' || error === null) { + return { message, messageLower, responseErrorText: '' } + } + + const record = error as Record + const axiosCode = typeof record.code === 'string' ? record.code : undefined + const response = typeof record.response === 'object' && record.response !== null + ? (record.response as Record) + : undefined + const httpStatus = typeof response?.status === 'number' ? response.status : undefined + const responseData = response?.data + const responseError = typeof responseData === 'object' && responseData !== null + ? (responseData as Record).error + : undefined + const responseErrorText = typeof responseError === 'string' ? responseError : '' + + return { + message, + messageLower, + axiosCode, + httpStatus, + responseErrorText + } +} + +/** + * Check if an error is a retryable connection error + * + * Retryable errors: + * - ECONNREFUSED - server not started + * - ETIMEDOUT - connection timeout + * - ENOTFOUND - DNS resolution failed + * - ENETUNREACH - network unreachable + * - ECONNRESET - connection reset + * - 5xx - server errors + * + * Non-retryable errors: + * - 401 - authentication failed + * - 403 - permission denied + * - 404 - endpoint not found + * - other 4xx errors + */ +export function isRetryableConnectionError(error: unknown): boolean { + const { axiosCode, httpStatus } = extractErrorInfo(error) + + // Retryable network errors + if (axiosCode === 'ECONNREFUSED' || + axiosCode === 'ETIMEDOUT' || + axiosCode === 'ENOTFOUND' || + axiosCode === 'ENETUNREACH' || + axiosCode === 'ECONNRESET') { + return true + } + + // 5xx server errors are retryable + if (httpStatus && httpStatus >= 500) { + return true + } + + // Other errors (401, 403, 404, etc.) are not retryable + return false +} diff --git a/cli/src/utils/time.ts b/cli/src/utils/time.ts index 9570d114..9045ffd0 100644 --- a/cli/src/utils/time.ts +++ b/cli/src/utils/time.ts @@ -38,4 +38,71 @@ export function createBackoff( }; } -export let backoff = createBackoff(); \ No newline at end of file +export let backoff = createBackoff(); + +/** + * Options for withRetry function + */ +export type RetryOptions = { + /** Maximum number of retry attempts. Default: unlimited */ + maxAttempts?: number + /** Minimum delay between retries in ms. Default: 1000 */ + minDelay?: number + /** Maximum delay between retries in ms. Default: 30000 */ + maxDelay?: number + /** Function to determine if error is retryable. Default: retry all errors */ + shouldRetry?: (error: unknown) => boolean + /** Callback when a retry is about to happen */ + onRetry?: (error: unknown, attempt: number, nextDelayMs: number) => void +} + +/** + * Execute a function with retry logic and exponential backoff + * + * Unlike createBackoff, this function: + * - Supports a shouldRetry predicate to skip non-retryable errors + * - Has sensible defaults for daemon-style long-running processes + * - Uses clearer exponential backoff (2^n with jitter) + */ +export async function withRetry( + fn: () => Promise, + options?: RetryOptions +): Promise { + const maxAttempts = options?.maxAttempts ?? Infinity + const minDelay = options?.minDelay ?? 1000 + const maxDelay = options?.maxDelay ?? 30000 + const shouldRetry = options?.shouldRetry ?? (() => true) + const onRetry = options?.onRetry + + let attempt = 0 + + while (true) { + try { + return await fn() + } catch (error) { + attempt++ + + // Check if we should retry this error + if (!shouldRetry(error)) { + throw error + } + + // Check if we've exceeded max attempts + if (attempt >= maxAttempts) { + throw error + } + + // Calculate delay with exponential backoff and jitter + const exponentialDelay = minDelay * Math.pow(2, attempt - 1) + const cappedDelay = Math.min(exponentialDelay, maxDelay) + const jitter = Math.random() * 0.3 * cappedDelay // 0-30% jitter + const nextDelayMs = Math.round(cappedDelay + jitter) + + if (onRetry) { + onRetry(error, attempt, nextDelayMs) + } + + await delay(nextDelayMs) + } + } +} \ No newline at end of file