修复完美像素对账请求的绝对截止
为 requestJson 增加覆盖鉴权、重试和响应体读取的可选绝对 deadline。 让完美像素项目对账按 75 秒窗口和单次 10 秒上界读取权威快照。 补充鉴权等待、响应体读取、客户端透传和对账边界的定向测试与文档。
This commit is contained in:
@@ -2710,7 +2710,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(loadEditorProjectMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'project-1',
|
||||
expect.objectContaining({ timeoutMs: 10_000 }),
|
||||
expect.objectContaining({ deadlineAt: expect.any(Number) }),
|
||||
);
|
||||
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1);
|
||||
expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject, {
|
||||
@@ -3197,7 +3197,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
});
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledWith(
|
||||
'project-1',
|
||||
expect.objectContaining({ timeoutMs: 10_000 }),
|
||||
expect.objectContaining({ deadlineAt: expect.any(Number) }),
|
||||
);
|
||||
expect(refreshAssetLibrary).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('dialog').textContent).not.toContain('failed');
|
||||
@@ -3862,9 +3862,16 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
'project-1',
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
timeoutMs: 10_000,
|
||||
deadlineAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
const readOptions = loadEditorProjectMock.mock.calls[0]?.[1] as
|
||||
| { deadlineAt?: number }
|
||||
| undefined;
|
||||
expect(readOptions?.deadlineAt).toBeLessThanOrEqual(Date.now() + 10_000);
|
||||
expect(readOptions?.deadlineAt).toBeLessThanOrEqual(
|
||||
hydratedDialog.perfectPixelOperation!.reconcileUntil,
|
||||
);
|
||||
expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled();
|
||||
expect(applyProjectSnapshotWithoutHistory).toHaveBeenCalledTimes(1);
|
||||
expect(applyProjectSnapshot).not.toHaveBeenCalled();
|
||||
@@ -3902,6 +3909,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
|
||||
it('observes an expired hydrated operation at least once and leaves it pending when no result exists', async () => {
|
||||
const operationId = 'perfect-pixel-hydrated-expired';
|
||||
const readStartedAt = Date.now();
|
||||
const hydratedDialog = createHydratedPerfectPixelDialog({
|
||||
operationId,
|
||||
reconcileUntil: Date.now() - 1_000,
|
||||
@@ -3930,6 +3938,11 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
|
||||
const readOptions = loadEditorProjectMock.mock.calls[0]?.[1] as
|
||||
| { deadlineAt?: number }
|
||||
| undefined;
|
||||
expect(readOptions?.deadlineAt).toBeGreaterThanOrEqual(readStartedAt);
|
||||
expect(readOptions?.deadlineAt).toBeLessThanOrEqual(Date.now() + 10_000);
|
||||
expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled();
|
||||
expect(applyProjectSnapshotWithoutHistory).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('dialog-error').textContent).toBe(
|
||||
|
||||
@@ -391,17 +391,18 @@ async function reconcilePerfectPixelProject(
|
||||
}
|
||||
try {
|
||||
hasAttemptedRead = true;
|
||||
const readStartedAt = Date.now();
|
||||
const remainingAtReadMs = operation.reconcileUntil - readStartedAt;
|
||||
const readDeadlineAt =
|
||||
remainingAtReadMs > 0
|
||||
? Math.min(
|
||||
operation.reconcileUntil,
|
||||
readStartedAt + PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS,
|
||||
)
|
||||
: readStartedAt + PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS;
|
||||
latestProject = await loadEditorProject(projectId, {
|
||||
signal: options.signal,
|
||||
timeoutMs: Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS,
|
||||
remainingAfterDelayMs > 0
|
||||
? remainingAfterDelayMs
|
||||
: PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS,
|
||||
),
|
||||
),
|
||||
deadlineAt: readDeadlineAt,
|
||||
});
|
||||
const verdict = inspectPerfectPixelProjectSnapshot(
|
||||
latestProject,
|
||||
|
||||
@@ -55,6 +55,16 @@ function createResponseMock(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('apiClient', () => {
|
||||
const fetchMock = vi.fn();
|
||||
const dispatchEventMock = vi.fn();
|
||||
@@ -687,6 +697,83 @@ describe('apiClient', () => {
|
||||
expect(capturedError).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it.each(['missing-token', 'unauthorized'] as const)(
|
||||
'bounds the %s refresh wait with an absolute request deadline',
|
||||
async (refreshMode) => {
|
||||
const refreshResponse = createDeferred<
|
||||
ReturnType<typeof createResponseMock>
|
||||
>();
|
||||
if (refreshMode === 'unauthorized') {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockImplementationOnce(() => refreshResponse.promise);
|
||||
} else {
|
||||
fetchMock.mockImplementationOnce(() => refreshResponse.promise);
|
||||
}
|
||||
|
||||
const request = requestJson(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'GET' },
|
||||
'读取受保护数据失败',
|
||||
{ deadlineAt: Date.now() + 20 },
|
||||
);
|
||||
|
||||
await expect(request).rejects.toMatchObject({ name: 'TimeoutError' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(
|
||||
refreshMode === 'unauthorized' ? 2 : 1,
|
||||
);
|
||||
expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('/api/auth/refresh');
|
||||
if (refreshMode === 'unauthorized') {
|
||||
expect(getStoredAccessToken()).toBe('expired-token');
|
||||
} else {
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
}
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
|
||||
refreshResponse.resolve(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: { token: 'late-refresh-token' },
|
||||
error: null,
|
||||
meta: { apiVersion: '2026-06-16' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(getStoredAccessToken()).toBe('late-refresh-token');
|
||||
});
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([200, 400])(
|
||||
'bounds a %i response body read with the same absolute request deadline',
|
||||
async (status) => {
|
||||
setStoredAccessToken('body-timeout-token', { emit: false });
|
||||
const responseBody = createDeferred<string>();
|
||||
const response = createResponseMock({ status });
|
||||
response.text.mockImplementationOnce(() => responseBody.promise);
|
||||
fetchMock.mockResolvedValueOnce(response);
|
||||
|
||||
const request = requestJson(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'GET' },
|
||||
'读取受保护数据失败',
|
||||
{ deadlineAt: Date.now() + 20 },
|
||||
);
|
||||
|
||||
await expect(request).rejects.toMatchObject({ name: 'TimeoutError' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(response.text).toHaveBeenCalledTimes(1);
|
||||
|
||||
responseBody.resolve('');
|
||||
await responseBody.promise;
|
||||
},
|
||||
);
|
||||
|
||||
it('surfaces response metadata through ApiClientError', async () => {
|
||||
setStoredAccessToken('metadata-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
|
||||
+142
-14
@@ -49,6 +49,12 @@ export type ApiRequestOptions = {
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type ApiJsonRequestOptions = ApiRequestOptions & {
|
||||
// 从 requestJson 入口起覆盖鉴权、重试、业务请求和响应体读取的绝对截止时间。
|
||||
// 未传时保持既有 timeoutMs 仅约束单次业务 fetch 的语义。
|
||||
deadlineAt?: number;
|
||||
};
|
||||
|
||||
export const BACKGROUND_AUTH_REQUEST_OPTIONS = {
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
@@ -317,6 +323,93 @@ function composeAbortSignal(
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -702,14 +795,17 @@ export async function fetchWithApiAuth(
|
||||
try {
|
||||
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
|
||||
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
|
||||
await ensureStoredAccessToken();
|
||||
await awaitWithAbortSignal(ensureStoredAccessToken(), requestSignal);
|
||||
requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (requestSignal?.aborted) {
|
||||
throw requestSignal.reason ?? error;
|
||||
}
|
||||
// 补票失败时继续走原始请求,让调用方按真实 401 分支处理。
|
||||
}
|
||||
}
|
||||
@@ -735,13 +831,16 @@ export async function fetchWithApiAuth(
|
||||
!refreshAttempted
|
||||
) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
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 &&
|
||||
@@ -771,6 +870,9 @@ export async function fetchWithApiAuth(
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestSignal?.aborted) {
|
||||
throw requestSignal.reason ?? error;
|
||||
}
|
||||
if (!shouldRetryError(error, attempt, retry)) {
|
||||
throw error;
|
||||
}
|
||||
@@ -784,8 +886,9 @@ export async function fetchWithApiAuth(
|
||||
async function buildApiClientError(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const responseText = await response.text();
|
||||
const responseText = await awaitWithAbortSignal(response.text(), signal);
|
||||
const parsedError = parseApiErrorShape(responseText);
|
||||
const requestId =
|
||||
parsedError?.meta.requestId ??
|
||||
@@ -820,17 +923,42 @@ export async function requestJson<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: ApiRequestOptions = {},
|
||||
options: ApiJsonRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await fetchWithApiAuth(url, init, options);
|
||||
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;
|
||||
|
||||
if (!response.ok) {
|
||||
throw await buildApiClientError(response, fallbackMessage);
|
||||
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();
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
return responseText
|
||||
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
|
||||
: (null as T);
|
||||
}
|
||||
|
||||
@@ -371,6 +371,33 @@ describe('editorProjectClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards an absolute lifecycle deadline when loading a project', async () => {
|
||||
const controller = new AbortController();
|
||||
const deadlineAt = Date.now() + 10_000;
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'editor-project-1',
|
||||
title: '角色设定板',
|
||||
viewport: { x: 8, y: 9, scale: 1.5 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await loadEditorProject('editor-project-1', {
|
||||
signal: controller.signal,
|
||||
deadlineAt,
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/projects/editor-project-1',
|
||||
{ method: 'GET', signal: controller.signal },
|
||||
'读取图片画布工程失败',
|
||||
{ deadlineAt },
|
||||
);
|
||||
});
|
||||
|
||||
it('renames and deletes an editor project', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -580,6 +580,7 @@ export type EditorCanvasSnapshot = {
|
||||
export type EditorProjectLoadOptions = {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
deadlineAt?: number;
|
||||
};
|
||||
|
||||
export type EditorProjectCreateInput = {
|
||||
@@ -789,6 +790,9 @@ export async function loadEditorProject(
|
||||
projectId: string,
|
||||
options: EditorProjectLoadOptions = {},
|
||||
) {
|
||||
const hasAbsoluteDeadline =
|
||||
typeof options.deadlineAt === 'number' &&
|
||||
Number.isFinite(options.deadlineAt);
|
||||
const response = await requestJson<EditorProjectResponse>(
|
||||
`${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`,
|
||||
{
|
||||
@@ -796,10 +800,11 @@ export async function loadEditorProject(
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
},
|
||||
'读取图片画布工程失败',
|
||||
// 中文注释:本文件多数接口都显式写了超时,这里此前没有——而 composeAbortSignal 在
|
||||
// timeoutMs 缺失时不设任何默认值。它是未知结果对账路径上的读取,挂住会让 catch 迟迟
|
||||
// 不结束,本会话对占位的归属登记要到 finally 才释放,连带把占位拖过存活窗口。
|
||||
{ timeoutMs: options.timeoutMs ?? 60_000 },
|
||||
hasAbsoluteDeadline
|
||||
? { deadlineAt: options.deadlineAt }
|
||||
: // 中文注释:普通项目读取继续保留既有单次 fetch timeout;完美像素对账显式传
|
||||
// deadlineAt,改由 requestJson 从鉴权恢复到响应体读取约束完整生命周期。
|
||||
{ timeoutMs: options.timeoutMs ?? 60_000 },
|
||||
);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user