Skip to content
Open
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
26 changes: 3 additions & 23 deletions src/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<unknown>;
};

type MinimalRequestInit = {
method?: string;
headers?: Record<string, string>;
};

type MinimalFetch = (input: string, init?: MinimalRequestInit) => Promise<MinimalResponse>;

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<string, string> = {
'Content-Type': 'application/json',
};
Expand All @@ -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 {
Expand Down
28 changes: 4 additions & 24 deletions src/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<unknown>;
};

type MinimalRequestInit = {
method?: string;
headers?: Record<string, string>;
body?: string;
};

type MinimalFetch = (input: string, init?: MinimalRequestInit) => Promise<MinimalResponse>;

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<string, string> = {
'Content-Type': 'application/json',
};
Expand All @@ -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 {
Expand Down
115 changes: 115 additions & 0 deletions src/utils/http.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
body?: string;
timeoutMs?: number;
maxRetries?: number;
verbose?: boolean;
}

interface FetchResponse {
ok: boolean;
status: number;
statusText: string;
json(): Promise<unknown>;
}

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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

export async function fetchWithRetry(
url: string,
options: FetchWithRetryOptions = {}
): Promise<FetchResponse> {
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');
}
Loading