mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-09-20 21:50:11 +08:00
- useHttp.calculateRetryDelay:优先读服务端 Retry-After 头;429 用指数退避 (1000*2^attempt, 封顶 30s);其余 5xx 仍固定延迟,避免限流场景频繁重试浪费带宽 - debian/changelog 测试版本号 5.2.1.2-test - 验证:vue-tsc 0 / eslint 0 / dpkg-buildpackage 打包成功 审查项实证(其余 3 项均误报/已有防护,不改): - ① install-manager pkgname 二次校验:已有 PKGNAME_PATTERN(/^[a-zA-Z0-9._+-]+$/) 在 parseAppPayload/addInstallTask/launch-app 三入口校验,且 spawn 全用 shell:false 参数逐元素传递,命令注入不可达;审查建议的 replace 属冗余纵深防御,不改 - ② InstalledAppsModal /var/lib/ 宽泛:误报,真实白名单(400-409行)已是 /var/lib/apm/ 与 /var/lib/dpkg/info/ 具体子目录,无宽泛 /var/lib/ 项 - ③ useAppState.ts 注入模式缺注册检查:误报,useAppState.ts 无 registerRequireLogin; 真实 registerRequireLogin 在 useFavorites/useAppDetail,默认 ()=>true 为刻意安全兜底 (未注册=放行),审查建议的 InjectionPoint 包装会破坏该语义,不改
101 lines
3.5 KiB
TypeScript
101 lines
3.5 KiB
TypeScript
/**
|
||
* useHttp —— 全局 HTTP 工具(axios 实例 + 带重试请求 + 超时包装)。
|
||
*
|
||
* 从原 App.vue 原样搬移,逻辑零改动,仅改为导出供各 composable 复用。
|
||
* 含缓存穿透拦截器(createCacheBusterInterceptor),与之前的审计加固一致。
|
||
*/
|
||
import axios, { AxiosError } from "axios";
|
||
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||
import { createCacheBusterInterceptor } from "../global/cacheBusterInterceptor";
|
||
|
||
// Axios 全局配置
|
||
export const axiosInstance = axios.create({
|
||
baseURL: APM_STORE_BASE_URL,
|
||
timeout: 5000, // 增加到 5 秒,避免网络波动导致的超时
|
||
});
|
||
|
||
// C2:数据 JSON(applist / categories / sidebar-config 等)追加 ?_t 版本戳,
|
||
// 穿透 CDN 边缘缓存,确保返回最新列表。复用共享缓存穿透拦截器(带 TTL 复用戳,
|
||
// 避免同会话频繁击穿缓存)。与主进程 onBeforeSendHeaders 注入的 no-cache 互为兜底。
|
||
// 这是有意的缓存击穿策略(非缺陷),用于解决商店目录强缓存导致的更新延迟。
|
||
axiosInstance.interceptors.request.use(createCacheBusterInterceptor());
|
||
|
||
// 5xx / 网络错误 / 超时重试;4xx(如 404)快速失败
|
||
const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
||
|
||
// 计算重试延迟:优先尊重服务端 Retry-After 头;429 限流用指数退避(封顶 30s);
|
||
// 其余 5xx 用固定延迟。避免对限流场景频繁重试浪费带宽。
|
||
const calculateRetryDelay = (
|
||
status: number | undefined,
|
||
attempt: number,
|
||
retryAfterHeader?: string,
|
||
fixedDelayMs = 500,
|
||
): number => {
|
||
if (retryAfterHeader) {
|
||
const seconds = Number.parseInt(retryAfterHeader, 10);
|
||
if (!Number.isNaN(seconds) && seconds > 0) return seconds * 1000;
|
||
}
|
||
if (status === 429) {
|
||
return Math.min(1000 * 2 ** attempt, 30000);
|
||
}
|
||
return fixedDelayMs;
|
||
};
|
||
|
||
export const fetchWithRetry = async <T>(
|
||
path: string,
|
||
signal?: AbortSignal,
|
||
retries = 2,
|
||
retryDelayMs = 500,
|
||
): Promise<T | null> => {
|
||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||
try {
|
||
const resp = await axiosInstance.get<T>(path, { signal });
|
||
return resp.data;
|
||
} catch (err) {
|
||
const ae = err as AxiosError;
|
||
const status = ae.response?.status;
|
||
// 4xx(含 404)快速失败,不重试
|
||
if (status && status < 500 && status !== 429) {
|
||
return null;
|
||
}
|
||
// 仅对网络错误 / 5xx / 429 重试
|
||
const retryable =
|
||
!status || RETRYABLE_STATUS.has(status) || status === 429;
|
||
if (!retryable || attempt === retries) {
|
||
return null;
|
||
}
|
||
const retryAfter = ae.response?.headers?.["retry-after"] as
|
||
| string
|
||
| undefined;
|
||
const delay = calculateRetryDelay(
|
||
status,
|
||
attempt,
|
||
retryAfter,
|
||
retryDelayMs,
|
||
);
|
||
await new Promise((r) => setTimeout(r, delay));
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// 全局请求取消控制器(目录加载期间可整体取消)
|
||
export const rootAbortController = new AbortController();
|
||
|
||
// 给 Promise 包一层超时,超时即 reject。finally 中清理定时器,避免泄漏。
|
||
export const withTimeout = async <T>(
|
||
promise: Promise<T>,
|
||
ms: number,
|
||
label = "operation",
|
||
): Promise<T> => {
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
const timeout = new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(`${label} 超时 (${ms}ms)`)), ms);
|
||
});
|
||
try {
|
||
return await Promise.race([promise, timeout]);
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
};
|