From 80a4446f8fe2575b06e85b6876deee49073f049f Mon Sep 17 00:00:00 2001 From: cunoe Date: Wed, 1 Apr 2026 21:57:30 +0800 Subject: [PATCH] fix: add timeout and retry mechanism for push/pull network requests (#14) - Create shared fetchWithRetry utility with 30s AbortController timeout - Add exponential backoff retry for 5xx errors and network failures (max 3 retries) - Replace raw fetch calls in push.ts and pull.ts with fetchWithRetry --- src/commands/pull.ts | 26 ++-------- src/commands/push.ts | 28 ++--------- src/utils/http.ts | 115 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 47 deletions(-) create mode 100644 src/utils/http.ts diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 8f6f471..eac76a7 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -10,6 +10,7 @@ import { } from '@/utils/url'; import { detectDefaultShell, exportEnv } from '@/utils/env'; import { getCredential } from '@/utils/credentials'; +import { fetchWithRetry } from '@/utils/http'; // env file updates will be handled via writeEnvs interface PullOptions { @@ -108,28 +109,6 @@ export function pullCommand(program: Command): void { // 发送 HTTP 请求 console.log(chalk.blue('📤 Fetching data from remote server...')); - type MinimalResponse = { - ok: boolean; - status: number; - statusText: string; - json(): Promise; - }; - - type MinimalRequestInit = { - method?: string; - headers?: Record; - }; - - type MinimalFetch = (input: string, init?: MinimalRequestInit) => Promise; - - const fetchFn: MinimalFetch | undefined = ( - globalThis as unknown as { fetch?: MinimalFetch } - ).fetch; - - if (!fetchFn) { - throw new Error('fetch is not available in this Node.js runtime. Please use Node 18+'); - } - const headers: Record = { 'Content-Type': 'application/json', }; @@ -140,9 +119,10 @@ export function pullCommand(program: Command): void { } headers['Authorization'] = `Bearer ${apiKey}`; - const response = await fetchFn(fullUrl, { + const response = await fetchWithRetry(fullUrl, { method: 'GET', headers, + verbose: options.verbose, }); const responseData = (await response.json()) as { diff --git a/src/commands/push.ts b/src/commands/push.ts index abb51b1..7d3cc07 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -6,6 +6,7 @@ import { ConfigManager } from '@/utils/config'; import { getEnvs } from '@/utils/com'; import { parseRef, buildPushUrl } from '@/utils/url'; import { getCredential } from '@/utils/credentials'; +import { fetchWithRetry } from '@/utils/http'; interface PushOptions { verbose?: boolean; @@ -86,29 +87,7 @@ export function pushCommand(program: Command): void { // 发送 HTTP 请求 console.log(chalk.blue('📤 Sending data to remote server...')); - - type MinimalResponse = { - ok: boolean; - status: number; - statusText: string; - json(): Promise; - }; - - type MinimalRequestInit = { - method?: string; - headers?: Record; - body?: string; - }; - type MinimalFetch = (input: string, init?: MinimalRequestInit) => Promise; - - const fetchFn: MinimalFetch | undefined = (globalThis as unknown as { fetch?: MinimalFetch }) - .fetch; - - if (!fetchFn) { - throw new Error('fetch is not available in this Node.js runtime. Please use Node 18+'); - } - const headers: Record = { 'Content-Type': 'application/json', }; @@ -119,10 +98,11 @@ export function pushCommand(program: Command): void { } headers['Authorization'] = `Bearer ${apiKey}`; - const response = await fetchFn(remoteUrl, { + const response = await fetchWithRetry(remoteUrl, { method: 'POST', headers, - body: JSON.stringify(payload) + body: JSON.stringify(payload), + verbose: options.verbose, }); const responseData = await response.json() as { diff --git a/src/utils/http.ts b/src/utils/http.ts new file mode 100644 index 0000000..bf095e8 --- /dev/null +++ b/src/utils/http.ts @@ -0,0 +1,115 @@ +import chalk from 'chalk'; + +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 1_000; + +interface FetchWithRetryOptions { + method?: string; + headers?: Record; + body?: string; + timeoutMs?: number; + maxRetries?: number; + verbose?: boolean; +} + +interface FetchResponse { + ok: boolean; + status: number; + statusText: string; + json(): Promise; +} + +function isRetryableStatus(status: number): boolean { + return status >= 500 && status < 600; +} + +function getDelayMs(attempt: number): number { + return BASE_DELAY_MS * Math.pow(2, attempt); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export async function fetchWithRetry( + url: string, + options: FetchWithRetryOptions = {} +): Promise { + const { + method = 'GET', + headers, + body, + timeoutMs = DEFAULT_TIMEOUT_MS, + maxRetries = MAX_RETRIES, + verbose = false, + } = options; + + const fetchFn = (globalThis as unknown as { fetch?: typeof fetch }).fetch; + if (!fetchFn) { + throw new Error('fetch is not available in this Node.js runtime. Please use Node 18+'); + } + + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchFn(url, { + method, + headers, + body, + signal: controller.signal, + }); + + clearTimeout(timer); + + // 5xx 错误且还有重试次数时,进行重试 + if (isRetryableStatus(response.status) && attempt < maxRetries) { + const delay = getDelayMs(attempt); + if (verbose) { + console.log( + chalk.yellow( + `⚠️ Server returned ${response.status}, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})...` + ) + ); + } + await sleep(delay); + continue; + } + + return response; + } catch (error) { + clearTimeout(timer); + + const isTimeout = + error instanceof DOMException && error.name === 'AbortError'; + const isNetworkError = error instanceof TypeError; + + if (isTimeout) { + lastError = new Error(`Request timed out after ${timeoutMs}ms`); + } else if (isNetworkError) { + lastError = new Error(`Network error: ${(error as Error).message}`); + } else { + lastError = error instanceof Error ? error : new Error(String(error)); + } + + if (attempt < maxRetries) { + const delay = getDelayMs(attempt); + if (verbose) { + console.log( + chalk.yellow( + `⚠️ ${lastError.message}, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})...` + ) + ); + } + await sleep(delay); + continue; + } + } + } + + throw lastError ?? new Error('Request failed after retries'); +}