Files
Genarrative/src/services/apiClient.ts
T
k88936 fc0dcb0782
Project CI / Repository checks (push) Successful in 50s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
修复泥点消耗刷新不及时 (#138)
refactor:
- 主站复用game agent的zustand store

fix:
- 修复旧请求覆盖问题
- 修复账号切换问题

- 主站原本已经每 4 秒轮询 external generation 任务状态, 在这里补充触发余额刷新的时机

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/138
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-08 10:25:02 +08:00

1055 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
import {
API_RESPONSE_ENVELOPE_HEADER,
API_RESPONSE_ENVELOPE_VERSION,
API_VERSION,
type ApiErrorPayload,
type ApiMeta,
parseApiErrorMessage,
unwrapApiResponse,
} from '../../packages/shared/src/http';
import { getHostRuntime } from './host-bridge/hostBridge';
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
const REQUEST_ID_HEADER = 'x-request-id';
const API_VERSION_HEADER = 'x-api-version';
const ROUTE_VERSION_HEADER = 'x-route-version';
const CLIENT_TYPE_HEADER = 'x-client-type';
const CLIENT_RUNTIME_HEADER = 'x-client-runtime';
const CLIENT_PLATFORM_HEADER = 'x-client-platform';
const MINI_PROGRAM_ENV_HEADER = 'x-mini-program-env';
const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 502, 503, 504];
const DEFAULT_SAFE_RETRY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export type ApiRetryOptions = {
maxRetries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
retryableStatusCodes?: number[];
retryUnsafeMethods?: boolean;
allowRetryMethods?: string[];
};
export type ApiAuthImpact = 'global' | 'local';
export type ApiRequestOptions = {
retry?: ApiRetryOptions;
timeoutMs?: number;
skipAuth?: boolean;
omitEnvelopeHeader?: boolean;
skipRefresh?: boolean;
// global:请求失败可影响整站登录态;local:失败只属于当前卡片、图片或运行态。
authImpact?: ApiAuthImpact;
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
notifyAuthStateChange?: boolean;
// 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。
clearAuthOnUnauthorized?: boolean;
// 同一次业务请求在客户端重试时复用 request id,后端据此做计费幂等。
requestId?: string;
};
export type ApiJsonRequestOptions = ApiRequestOptions & {
// 从 requestJson 入口起覆盖鉴权、重试、业务请求和响应体读取的绝对截止时间。
// 未传时保持既有 timeoutMs 仅约束单次业务 fetch 的语义。
deadlineAt?: number;
};
export const BACKGROUND_AUTH_REQUEST_OPTIONS = {
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
} satisfies ApiRequestOptions;
type ResolvedRetryOptions = {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
retryableStatusCodes: Set<number>;
retryUnsafeMethods: boolean;
allowRetryMethods: Set<string>;
method: string;
};
type ParsedApiErrorShape = {
code: string;
details: Record<string, unknown> | null;
meta: Partial<ApiMeta>;
};
type ResolvedAuthFailurePolicy = {
skipRefresh: boolean;
notifyAuthStateChange: boolean;
clearAuthOnUnauthorized: boolean;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function normalizeHeaders(headers?: HeadersInit) {
const nextHeaders: Record<string, string> = {};
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
headers.forEach((value, key) => {
nextHeaders[key] = value;
});
return nextHeaders;
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
nextHeaders[key] = value;
}
return nextHeaders;
}
if (headers) {
Object.assign(nextHeaders, headers);
}
return nextHeaders;
}
function hasHeader(headers: Record<string, string>, name: string) {
const normalizedName = name.toLowerCase();
return Object.keys(headers).some(
(key) => key.toLowerCase() === normalizedName,
);
}
function setHeaderIfMissing(
headers: Record<string, string>,
name: string,
value: string | null | undefined,
) {
const trimmedValue = value?.trim();
if (!trimmedValue || hasHeader(headers, name)) {
return;
}
headers[name] = trimmedValue;
}
function attachHostRuntimeHeaders(headers: Record<string, string>) {
let runtime: ReturnType<typeof getHostRuntime>;
try {
runtime = getHostRuntime();
} catch {
return headers;
}
if (runtime.kind === 'wechat_mini_program') {
setHeaderIfMissing(headers, CLIENT_TYPE_HEADER, 'mini_program');
setHeaderIfMissing(
headers,
CLIENT_RUNTIME_HEADER,
runtime.clientRuntime || 'wechat_mini_program',
);
setHeaderIfMissing(headers, CLIENT_PLATFORM_HEADER, runtime.hostPlatform);
setHeaderIfMissing(
headers,
MINI_PROGRAM_ENV_HEADER,
runtime.miniProgramEnv,
);
return headers;
}
if (runtime.kind === 'native_app') {
setHeaderIfMissing(headers, CLIENT_TYPE_HEADER, 'native_app');
setHeaderIfMissing(
headers,
CLIENT_RUNTIME_HEADER,
runtime.clientRuntime || 'native_app',
);
setHeaderIfMissing(headers, CLIENT_PLATFORM_HEADER, runtime.hostPlatform);
}
return headers;
}
function buildClientRequestId() {
const randomId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return `web-${randomId}`;
}
function resolveRequestIdHeader(
headers: Record<string, string>,
options: ApiRequestOptions,
) {
const explicitRequestId = options.requestId?.trim();
const existingRequestId = Object.entries(headers).find(
([key, value]) => key.toLowerCase() === REQUEST_ID_HEADER && value.trim(),
)?.[1];
return explicitRequestId || existingRequestId || buildClientRequestId();
}
function coerceMeta(value: unknown): Partial<ApiMeta> {
if (!isRecord(value)) {
return {};
}
return {
apiVersion:
typeof value.apiVersion === 'string' && value.apiVersion.trim()
? value.apiVersion.trim()
: undefined,
requestId:
typeof value.requestId === 'string' && value.requestId.trim()
? value.requestId.trim()
: undefined,
routeVersion:
typeof value.routeVersion === 'string' && value.routeVersion.trim()
? value.routeVersion.trim()
: undefined,
operation:
typeof value.operation === 'string' && value.operation.trim()
? value.operation.trim()
: value.operation === null
? null
: undefined,
latencyMs:
typeof value.latencyMs === 'number' && Number.isFinite(value.latencyMs)
? value.latencyMs
: undefined,
timestamp:
typeof value.timestamp === 'string' && value.timestamp.trim()
? value.timestamp.trim()
: undefined,
};
}
function parseApiErrorShape(rawText: string): ParsedApiErrorShape | null {
if (!rawText.trim()) {
return null;
}
try {
const parsed = JSON.parse(rawText) as
| {
error?: ApiErrorPayload;
meta?: Partial<ApiMeta>;
code?: string;
details?: Record<string, unknown> | null;
}
| Record<string, unknown>;
if (isRecord(parsed.error)) {
return {
code:
typeof parsed.error.code === 'string' && parsed.error.code.trim()
? parsed.error.code.trim()
: 'HTTP_ERROR',
details:
isRecord(parsed.error.details) || parsed.error.details === null
? (parsed.error.details as Record<string, unknown> | null)
: null,
meta: coerceMeta(parsed.meta),
};
}
if (typeof parsed.code === 'string' && parsed.code.trim()) {
return {
code: parsed.code.trim(),
details:
isRecord(parsed.details) || parsed.details === null
? (parsed.details as Record<string, unknown> | null)
: null,
meta: coerceMeta(parsed.meta),
};
}
} catch {
// Ignore malformed json responses.
}
return null;
}
function createAbortError() {
if (typeof DOMException !== 'undefined') {
return new DOMException('The operation was aborted.', 'AbortError');
}
const error = new Error('The operation was aborted.');
error.name = 'AbortError';
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,
};
}
function composeAbsoluteDeadlineSignal(
signal: AbortSignal | undefined,
deadlineAt: number | undefined,
) {
const hasDeadline =
typeof deadlineAt === 'number' && Number.isFinite(deadlineAt);
if (!hasDeadline) {
return {
signal,
hasDeadline: false,
cleanup: () => {},
};
}
const controller = new AbortController();
const remainingMs = Math.max(0, deadlineAt - Date.now());
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
signal?.removeEventListener('abort', onAbort);
};
const onAbort = () => {
controller.abort(signal?.reason ?? createAbortError());
};
if (signal?.aborted) {
controller.abort(signal.reason ?? createAbortError());
} else {
signal?.addEventListener('abort', onAbort, { once: true });
if (remainingMs <= 0) {
controller.abort(createTimeoutError(0));
} else {
timeoutId = setTimeout(() => {
controller.abort(createTimeoutError(remainingMs));
}, remainingMs);
}
}
return {
signal: controller.signal,
hasDeadline: true,
cleanup,
};
}
function awaitWithAbortSignal<T>(
work: Promise<T>,
signal?: AbortSignal,
): Promise<T> {
if (!signal) {
return work;
}
return new Promise<T>((resolve, reject) => {
let settled = false;
const cleanup = () => {
signal.removeEventListener('abort', onAbort);
};
const settle = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
cleanup();
callback();
};
const onAbort = () => {
settle(() => reject(signal.reason ?? createAbortError()));
};
signal.addEventListener('abort', onAbort, { once: true });
work.then(
(value) => {
settle(() => resolve(value));
},
(error: unknown) => {
settle(() => reject(error));
},
);
if (signal.aborted) {
onAbort();
}
});
}
async function waitForRetry(ms: number, signal?: AbortSignal) {
if (ms <= 0) {
return;
}
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = () => {
cleanup();
reject(signal?.reason ?? createAbortError());
};
const cleanup = () => {
clearTimeout(timeoutId);
signal?.removeEventListener('abort', onAbort);
};
if (signal?.aborted) {
cleanup();
reject(signal.reason ?? createAbortError());
return;
}
signal?.addEventListener('abort', onAbort, { once: true });
});
}
function resolveRetryOptions(
method: string,
retry?: ApiRetryOptions,
): ResolvedRetryOptions {
const normalizedMethod = method.toUpperCase();
const defaultMaxRetries = DEFAULT_SAFE_RETRY_METHODS.has(normalizedMethod)
? 1
: 0;
return {
maxRetries:
typeof retry?.maxRetries === 'number' && retry.maxRetries >= 0
? Math.floor(retry.maxRetries)
: defaultMaxRetries,
baseDelayMs:
typeof retry?.baseDelayMs === 'number' && retry.baseDelayMs > 0
? retry.baseDelayMs
: 250,
maxDelayMs:
typeof retry?.maxDelayMs === 'number' && retry.maxDelayMs > 0
? retry.maxDelayMs
: 1500,
retryableStatusCodes: new Set(
retry?.retryableStatusCodes?.length
? retry.retryableStatusCodes
: DEFAULT_RETRYABLE_STATUS_CODES,
),
retryUnsafeMethods: retry?.retryUnsafeMethods === true,
allowRetryMethods: new Set(
(retry?.allowRetryMethods ?? []).map((value) => value.toUpperCase()),
),
method: normalizedMethod,
};
}
function shouldRetryResponse(
status: number,
attempt: number,
retry: ResolvedRetryOptions,
) {
if (attempt >= retry.maxRetries) {
return false;
}
if (!retry.retryableStatusCodes.has(status)) {
return false;
}
return (
retry.retryUnsafeMethods ||
DEFAULT_SAFE_RETRY_METHODS.has(retry.method) ||
retry.allowRetryMethods.has(retry.method)
);
}
export function isAbortError(error: unknown) {
return (
error instanceof Error &&
(error.name === 'AbortError' ||
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError'))
);
}
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;
}
return error instanceof TypeError;
}
function buildRetryDelayMs(attempt: number, retry: ResolvedRetryOptions) {
return Math.min(retry.maxDelayMs, retry.baseDelayMs * Math.max(1, attempt));
}
function resolveAuthFailurePolicy(
options: ApiRequestOptions,
): ResolvedAuthFailurePolicy {
const isLocalAuthImpact = options.authImpact === 'local';
return {
// 局部后台请求可以携带已有 token,但不能主动 refresh
// 否则 refresh 失败会把一次卡片/图片/运行态失败放大成全局掉线。
skipRefresh: isLocalAuthImpact || options.skipRefresh === true,
notifyAuthStateChange: isLocalAuthImpact
? false
: options.notifyAuthStateChange !== false,
clearAuthOnUnauthorized: isLocalAuthImpact
? false
: options.clearAuthOnUnauthorized !== false,
};
}
// 中文注释:这些错误体由 Pingora 网关自己合成,应用层从未产生过——它们只有 code / message
// 没有 details,所以任何「有 details 标记就是已知结果」的判定对它们都不成立。
// 只收上游/代理这三类:请求可能已经到达 api-server 并被执行完,只是响应没回到客户端,
// 属于未知结果。GATEWAY_RATE_LIMITED / GATEWAY_CONCURRENCY_LIMITED / PAYLOAD_TOO_LARGE
// 是在网关就被拒、根本没到应用,属于确定失败,收进来会让普通节流也弹出「请核对素材库」,
// 变成反向谎报。
const GATEWAY_UNKNOWN_OUTCOME_ERROR_CODES = new Set([
'GATEWAY_UPSTREAM_ERROR',
'GATEWAY_UPSTREAM_TIMEOUT',
'GATEWAY_PROXY_ERROR',
]);
/**
* 中文注释:该错误是否由网关合成、因而无法证明应用层没有执行过这次请求。
*
* 有副作用的写接口在判断「结果是否已知」时必须把它算作未知:拿到 HTTP 响应不等于服务端
* 明确表态过,网关超时或上游断连时 api-server 可能已经完成了持久化。
*/
export function isGatewayUnknownOutcomeError(error: unknown): boolean {
return (
error instanceof ApiClientError &&
GATEWAY_UNKNOWN_OUTCOME_ERROR_CODES.has(error.code)
);
}
export class ApiClientError extends Error {
status: number;
code: string;
details: Record<string, unknown> | null;
meta: ApiMeta;
responseText: string;
constructor(params: {
message: string;
status: number;
code: string;
details?: Record<string, unknown> | null;
meta?: Partial<ApiMeta>;
responseText?: string;
}) {
super(params.message);
this.name = 'ApiClientError';
this.status = params.status;
this.code = params.code;
this.details = params.details ?? null;
this.meta = {
apiVersion: params.meta?.apiVersion ?? API_VERSION,
requestId: params.meta?.requestId,
routeVersion: params.meta?.routeVersion,
operation: params.meta?.operation,
latencyMs: params.meta?.latencyMs,
timestamp: params.meta?.timestamp,
};
this.responseText = params.responseText ?? '';
}
}
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
export function emitAuthStateChange() {
if (typeof window === 'undefined') {
return;
}
if (typeof CustomEvent === 'function') {
window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT));
return;
}
if (typeof Event === 'function') {
window.dispatchEvent(new Event(AUTH_STATE_EVENT));
}
}
export function getStoredAccessToken() {
if (!canUseLocalStorage()) {
return '';
}
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
}
let authStateGeneration = 0;
function writeStoredAccessToken(
token: string,
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
const nextToken = token.trim();
const previousToken = getStoredAccessToken();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
}
// 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。
if (options.emit !== false && previousToken !== nextToken) {
emitAuthStateChange();
}
}
export function setStoredAccessToken(
token: string,
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
authStateGeneration += 1;
writeStoredAccessToken(token, options);
}
export function clearStoredAccessToken(
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
authStateGeneration += 1;
const previousToken = getStoredAccessToken();
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
// 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。
if (options.emit !== false && previousToken) {
emitAuthStateChange();
}
}
function withAuthorizationHeaders(
headers?: HeadersInit,
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
) {
const nextHeaders = attachHostRuntimeHeaders(normalizeHeaders(headers));
const token = getStoredAccessToken();
if (token && !options.skipAuth) {
nextHeaders.Authorization = `Bearer ${token}`;
}
if (!options.omitEnvelopeHeader) {
nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION;
}
return nextHeaders;
}
type AuthStateSnapshot = {
generation: number;
accessToken: string;
};
type RefreshAccessTokenAttempt = AuthStateSnapshot & {
promise: Promise<string>;
};
class AuthStateChangedDuringRefreshError extends Error {
constructor() {
super('刷新期间登录状态已变化');
this.name = 'AuthStateChangedDuringRefreshError';
}
}
let refreshAccessTokenAttempt: RefreshAccessTokenAttempt | null = null;
function captureAuthStateSnapshot(): AuthStateSnapshot {
return {
generation: authStateGeneration,
accessToken: getStoredAccessToken(),
};
}
function isCurrentAuthState(snapshot: AuthStateSnapshot) {
return (
authStateGeneration === snapshot.generation &&
getStoredAccessToken() === snapshot.accessToken
);
}
function publishRefreshedAccessToken(
nextToken: string,
snapshot: AuthStateSnapshot,
) {
if (!isCurrentAuthState(snapshot)) {
throw new AuthStateChangedDuringRefreshError();
}
// refresh 只轮换同一账号的 access token,不推进账号代际。
// 外部登录、切号或退出通过公开 setter 推进代际,使旧 refresh 发布失效。
writeStoredAccessToken(nextToken, { emit: false });
}
function shouldClearAuthAfterRefreshFailure(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
);
}
async function refreshAccessToken() {
const authStateSnapshot = captureAuthStateSnapshot();
if (
refreshAccessTokenAttempt &&
refreshAccessTokenAttempt.generation === authStateSnapshot.generation &&
refreshAccessTokenAttempt.accessToken === authStateSnapshot.accessToken
) {
return refreshAccessTokenAttempt.promise;
}
const promise = (async () => {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'same-origin',
headers: attachHostRuntimeHeaders({
[API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION,
}),
});
if (!response.ok) {
throw await buildApiClientError(response, '刷新登录状态失败');
}
const responseText = await response.text();
const payload = responseText
? unwrapApiResponse<AuthRefreshResponse>(
JSON.parse(responseText) as AuthRefreshResponse,
)
: null;
const nextToken = payload?.token?.trim();
if (!nextToken) {
throw new Error('刷新登录状态失败');
}
publishRefreshedAccessToken(nextToken, authStateSnapshot);
return nextToken;
})();
const attempt: RefreshAccessTokenAttempt = {
...authStateSnapshot,
promise,
};
refreshAccessTokenAttempt = attempt;
try {
return await promise;
} finally {
if (refreshAccessTokenAttempt === attempt) {
refreshAccessTokenAttempt = null;
}
}
}
export async function ensureStoredAccessToken() {
const currentToken = getStoredAccessToken();
if (currentToken) {
return currentToken;
}
// AuthGate 恢复会话时可能只有 HttpOnly refresh cookie,本地尚无 access token。
return refreshAccessToken();
}
export async function refreshStoredAccessToken(
options: {
clearOnFailure?: boolean;
} = {},
) {
const authStateSnapshot = captureAuthStateSnapshot();
try {
return await refreshAccessToken();
} catch (error) {
if (
options.clearOnFailure !== false &&
shouldClearAuthAfterRefreshFailure(error) &&
isCurrentAuthState(authStateSnapshot)
) {
clearStoredAccessToken({ emit: false });
}
throw error;
}
}
export async function fetchWithApiAuth(
input: string,
init: RequestInit = {},
options: ApiRequestOptions = {},
) {
const method = (init.method ?? 'GET').toUpperCase();
const retry = resolveRetryOptions(method, options.retry);
const authFailurePolicy = resolveAuthFailurePolicy(options);
const requestSignal = init.signal ?? undefined;
const requestId = resolveRequestIdHeader(
normalizeHeaders(init.headers),
options,
);
let attempt = 0;
let refreshAttempted = false;
for (;;) {
try {
let requestHeaders = withAuthorizationHeaders(init.headers, options);
requestHeaders[REQUEST_ID_HEADER] = requestId;
let hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
if (
!hasAuthHeader &&
!options.skipAuth &&
!authFailurePolicy.skipRefresh
) {
try {
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
await awaitWithAbortSignal(ensureStoredAccessToken(), requestSignal);
requestHeaders = withAuthorizationHeaders(init.headers, options);
requestHeaders[REQUEST_ID_HEADER] = requestId;
hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
} catch (error) {
if (requestSignal?.aborted) {
throw requestSignal.reason ?? error;
}
// 补票失败时继续走原始请求,让调用方按真实 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 &&
hasAuthHeader &&
!options.skipAuth &&
!authFailurePolicy.skipRefresh &&
!refreshAttempted
) {
const refreshAuthStateSnapshot = captureAuthStateSnapshot();
try {
await awaitWithAbortSignal(refreshAccessToken(), requestSignal);
refreshAttempted = true;
// refresh 成功只代表 access token 已补票成功,
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
continue;
} catch (refreshError) {
if (requestSignal?.aborted) {
throw requestSignal.reason ?? refreshError;
}
const shouldClearAuth =
hasAuthHeader &&
authFailurePolicy.clearAuthOnUnauthorized &&
shouldClearAuthAfterRefreshFailure(refreshError) &&
isCurrentAuthState(refreshAuthStateSnapshot);
if (shouldClearAuth) {
clearStoredAccessToken({ emit: false });
}
if (shouldClearAuth && authFailurePolicy.notifyAuthStateChange) {
emitAuthStateChange();
}
}
} else if (
response.status === 401 &&
hasAuthHeader &&
!options.skipAuth &&
!refreshAttempted
) {
if (authFailurePolicy.clearAuthOnUnauthorized) {
clearStoredAccessToken({ emit: false });
}
if (authFailurePolicy.notifyAuthStateChange) {
emitAuthStateChange();
}
}
if (!shouldRetryResponse(response.status, attempt, retry)) {
return response;
}
} catch (error) {
if (requestSignal?.aborted) {
throw requestSignal.reason ?? error;
}
if (!shouldRetryError(error, attempt, retry)) {
throw error;
}
}
attempt += 1;
await waitForRetry(buildRetryDelayMs(attempt, retry), requestSignal);
}
}
async function buildApiClientError(
response: Response,
fallbackMessage: string,
signal?: AbortSignal,
) {
const responseText = await awaitWithAbortSignal(response.text(), signal);
const parsedError = parseApiErrorShape(responseText);
const requestId =
parsedError?.meta.requestId ??
response.headers.get(REQUEST_ID_HEADER) ??
undefined;
const baseMessage = parseApiErrorMessage(responseText, fallbackMessage);
return new ApiClientError({
message: requestId
? `${baseMessage}requestId: ${requestId}`
: baseMessage,
status: response.status,
code: parsedError?.code ?? `HTTP_${response.status || 0}`,
details: parsedError?.details ?? null,
meta: {
apiVersion:
parsedError?.meta.apiVersion ??
response.headers.get(API_VERSION_HEADER) ??
API_VERSION,
requestId,
routeVersion:
parsedError?.meta.routeVersion ??
response.headers.get(ROUTE_VERSION_HEADER) ??
undefined,
operation: parsedError?.meta.operation,
latencyMs: parsedError?.meta.latencyMs,
timestamp: parsedError?.meta.timestamp,
},
responseText,
});
}
export async function requestJson<T>(
url: string,
init: RequestInit,
fallbackMessage: string,
options: ApiJsonRequestOptions = {},
): Promise<T> {
const lifecycle = composeAbsoluteDeadlineSignal(
init.signal ?? undefined,
options.deadlineAt,
);
const requestOptions = lifecycle.hasDeadline
? { ...options, timeoutMs: undefined }
: options;
const requestInit = lifecycle.signal
? { ...init, signal: lifecycle.signal }
: init;
try {
const response = await fetchWithApiAuth(url, requestInit, requestOptions);
if (!response.ok) {
throw await buildApiClientError(
response,
fallbackMessage,
lifecycle.signal,
);
}
const responseText = await awaitWithAbortSignal(
response.text(),
lifecycle.signal,
);
if (lifecycle.signal?.aborted) {
throw lifecycle.signal.reason ?? createAbortError();
}
return responseText
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
: (null as T);
} finally {
lifecycle.cleanup();
}
}