This commit is contained in:
2026-04-22 23:44:57 +08:00
parent 76ac9d22a5
commit 84dc92646a
484 changed files with 9598 additions and 9135 deletions

View File

@@ -30,6 +30,7 @@ export type ApiRetryOptions = {
export type ApiRequestOptions = {
retry?: ApiRetryOptions;
timeoutMs?: number;
skipAuth?: boolean;
omitEnvelopeHeader?: boolean;
skipRefresh?: boolean;
@@ -172,6 +173,57 @@ function createAbortError() {
return error;
}
function createTimeoutError(timeoutMs: number) {
const error = new Error(`请求超时:${timeoutMs}ms`);
error.name = 'TimeoutError';
return error;
}
function composeAbortSignal(
signal: AbortSignal | undefined,
timeoutMs: number | undefined,
) {
const shouldUseTimeout =
typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0;
if (!shouldUseTimeout) {
return {
signal,
cleanup: () => {},
};
}
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort(createTimeoutError(timeoutMs));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeoutId);
signal?.removeEventListener('abort', onAbort);
};
const onAbort = () => {
controller.abort(signal?.reason ?? createAbortError());
};
if (signal?.aborted) {
cleanup();
controller.abort(signal.reason ?? createAbortError());
return {
signal: controller.signal,
cleanup,
};
}
signal?.addEventListener('abort', onAbort, { once: true });
return {
signal: controller.signal,
cleanup,
};
}
async function waitForRetry(ms: number, signal?: AbortSignal) {
if (ms <= 0) {
return;
@@ -268,6 +320,10 @@ export function isAbortError(error: unknown) {
);
}
export function isTimeoutError(error: unknown) {
return error instanceof Error && error.name === 'TimeoutError';
}
function shouldRetryError(error: unknown, attempt: number, retry: ResolvedRetryOptions) {
if (attempt >= retry.maxRetries || isAbortError(error)) {
return false;
@@ -505,21 +561,48 @@ export async function fetchWithApiAuth(
const method = (init.method ?? 'GET').toUpperCase();
const retry = resolveRetryOptions(method, options.retry);
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
const requestSignal = init.signal ?? undefined;
let attempt = 0;
let refreshAttempted = false;
for (;;) {
try {
const requestHeaders = withAuthorizationHeaders(init.headers, options);
const hasAuthHeader = Boolean(
let requestHeaders = withAuthorizationHeaders(init.headers, options);
let hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
const response = await fetch(input, {
credentials: 'same-origin',
...init,
headers: requestHeaders,
});
if (!hasAuthHeader && !options.skipAuth && !options.skipRefresh) {
try {
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
await ensureStoredAccessToken();
requestHeaders = withAuthorizationHeaders(init.headers, options);
hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
} catch {
// 补票失败时继续走原始请求,让调用方按真实 401 分支处理。
}
}
const timedRequest = composeAbortSignal(
requestSignal,
options.timeoutMs,
);
let response: Response;
try {
response = await fetch(input, {
credentials: 'same-origin',
...init,
signal: timedRequest.signal,
headers: requestHeaders,
});
} finally {
timedRequest.cleanup();
}
if (
response.status === 401 &&
@@ -543,7 +626,12 @@ export async function fetchWithApiAuth(
emitAuthStateChange();
}
}
} else if (response.status === 401 && hasAuthHeader && !options.skipAuth) {
} else if (
response.status === 401 &&
hasAuthHeader &&
!options.skipAuth &&
!refreshAttempted
) {
clearStoredAccessToken({ emit: false });
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
@@ -562,7 +650,7 @@ export async function fetchWithApiAuth(
attempt += 1;
await waitForRetry(
buildRetryDelayMs(attempt, retry),
init.signal ?? undefined,
requestSignal,
);
}
}