116 lines
3.3 KiB
TypeScript
116 lines
3.3 KiB
TypeScript
import "server-only";
|
||
|
||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||
const DEFAULT_RETRY_COUNT = 3;
|
||
const RETRY_DELAY_MS = 1_000;
|
||
|
||
function sleep(ms: number) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
export function describeRemoteAssetLocation(url: string) {
|
||
const normalizedUrl = String(url || "").trim();
|
||
if (!normalizedUrl) {
|
||
return "未知地址";
|
||
}
|
||
|
||
try {
|
||
const parsed = new URL(normalizedUrl);
|
||
return parsed.host || parsed.hostname || normalizedUrl;
|
||
} catch {
|
||
return normalizedUrl.length > 80 ? `${normalizedUrl.slice(0, 77)}...` : normalizedUrl;
|
||
}
|
||
}
|
||
|
||
function isRetryableStatus(status: number) {
|
||
return status === 408 || status === 425 || status === 429 || status >= 500;
|
||
}
|
||
|
||
function isRetryableFetchError(error: unknown) {
|
||
if (!(error instanceof Error)) {
|
||
return true;
|
||
}
|
||
|
||
const message = `${error.name} ${error.message}`.toLowerCase();
|
||
return (
|
||
error.name === "AbortError" ||
|
||
message.includes("fetch failed") ||
|
||
message.includes("timeout") ||
|
||
message.includes("timed out") ||
|
||
message.includes("econnreset") ||
|
||
message.includes("socket") ||
|
||
message.includes("network") ||
|
||
message.includes("terminated") ||
|
||
message.includes("und_err")
|
||
);
|
||
}
|
||
|
||
export async function fetchRemoteAssetBuffer(
|
||
url: string,
|
||
options?: {
|
||
label?: string;
|
||
timeoutMs?: number;
|
||
retries?: number;
|
||
}
|
||
) {
|
||
const normalizedUrl = String(url || "").trim();
|
||
const label = String(options?.label || "远程资源").trim() || "远程资源";
|
||
if (!normalizedUrl) {
|
||
throw new Error(`${label}地址为空`);
|
||
}
|
||
|
||
const attempts = Math.max(1, Math.round(Number(options?.retries || DEFAULT_RETRY_COUNT)));
|
||
const timeoutMs = Math.max(5_000, Math.round(Number(options?.timeoutMs || DEFAULT_TIMEOUT_MS)));
|
||
const location = describeRemoteAssetLocation(normalizedUrl);
|
||
|
||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||
try {
|
||
const response = await fetch(normalizedUrl, {
|
||
cache: "no-store",
|
||
signal: AbortSignal.timeout(timeoutMs),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const statusError = new Error(`${label}下载失败(${response.status})`);
|
||
if (attempt < attempts && isRetryableStatus(response.status)) {
|
||
console.warn("[PSD Remote Asset] transient status, retrying download:", {
|
||
label,
|
||
location,
|
||
status: response.status,
|
||
attempt,
|
||
attempts,
|
||
});
|
||
await sleep(RETRY_DELAY_MS * attempt);
|
||
continue;
|
||
}
|
||
|
||
throw statusError;
|
||
}
|
||
|
||
return Buffer.from(await response.arrayBuffer());
|
||
} catch (error) {
|
||
if (attempt < attempts && isRetryableFetchError(error)) {
|
||
console.warn("[PSD Remote Asset] transient fetch error, retrying download:", {
|
||
label,
|
||
location,
|
||
attempt,
|
||
attempts,
|
||
error,
|
||
});
|
||
await sleep(RETRY_DELAY_MS * attempt);
|
||
continue;
|
||
}
|
||
|
||
console.error("[PSD Remote Asset] failed to download asset:", {
|
||
label,
|
||
location,
|
||
attemptsTried: attempt,
|
||
error,
|
||
});
|
||
throw new Error(`${label}下载失败,请稍后重试或检查资源是否可访问(${location})`);
|
||
}
|
||
}
|
||
|
||
throw new Error(`${label}下载失败,请稍后重试或检查资源是否可访问(${location})`);
|
||
}
|