为图片编辑器上传和生成加自动重试

新增图片编辑器请求重试配置

生成请求遇到429等临时错误自动重试

上传凭证、资产确认和直传上传共用重试策略

补充图片编辑器重试参数和直传429测试
This commit is contained in:
2026-06-23 16:30:54 +08:00
parent 14f8a87ac5
commit 9ad2152816
8 changed files with 195 additions and 83 deletions
@@ -14,6 +14,11 @@ vi.mock('../assetReadUrlService', () => ({
const requestJsonMock = vi.mocked(requestJson);
const getSignedAssetReadUrlMock = vi.mocked(getSignedAssetReadUrl);
const editorRetryOptionsExpectation = expect.objectContaining({
maxRetries: 2,
retryUnsafeMethods: true,
retryableStatusCodes: expect.arrayContaining([429]),
});
describe('editorMediaAssetUploadClient', () => {
beforeEach(() => {
@@ -64,6 +69,17 @@ describe('editorMediaAssetUploadClient', () => {
'video',
);
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
'/api/assets/direct-upload-tickets',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.any(String),
}),
'创建素材上传凭证失败',
expect.objectContaining({ retry: editorRetryOptionsExpectation }),
);
const ticketBody = JSON.parse(
(requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string,
);
@@ -90,6 +106,17 @@ describe('editorMediaAssetUploadClient', () => {
const confirmBody = JSON.parse(
(requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string,
);
expect(requestJsonMock).toHaveBeenNthCalledWith(
2,
'/api/assets/objects/confirm',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.any(String),
}),
'确认素材资产失败',
expect.objectContaining({ retry: editorRetryOptionsExpectation }),
);
expect(confirmBody).toMatchObject({
bucket: 'bucket',
objectKey: 'generated-character-drafts/editor/asset-library/video/demo.mp4',
@@ -1,5 +1,9 @@
import { requestJson } from '../apiClient';
import { getSignedAssetReadUrl } from '../assetReadUrlService';
import {
EDITOR_REQUEST_RETRY_OPTIONS,
postEditorDirectUploadFile,
} from './editorRetryOptions';
export type EditorMediaAssetUploadType = 'video' | 'audio';
@@ -48,28 +52,6 @@ function sanitizeEditorMediaFileName(
return trimmedName || resolveFallbackFileName(mediaType);
}
async function postDirectUploadFile(
upload: DirectUploadTicketResponse['upload'],
file: File,
) {
const formData = new FormData();
Object.entries(upload.formFields).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
formData.append('file', file, file.name);
const response = await fetch(upload.host, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传素材失败');
}
}
export async function uploadEditorMediaAssetFile(
file: File,
mediaType: EditorMediaAssetUploadType,
@@ -98,9 +80,10 @@ export async function uploadEditorMediaAssetFile(
}),
},
'创建素材上传凭证失败',
{ retry: EDITOR_REQUEST_RETRY_OPTIONS },
);
await postDirectUploadFile(ticket.upload, file);
await postEditorDirectUploadFile(ticket.upload, file, '上传素材失败');
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
'/api/assets/objects/confirm',
@@ -118,6 +101,7 @@ export async function uploadEditorMediaAssetFile(
}),
},
'确认素材资产失败',
{ retry: EDITOR_REQUEST_RETRY_OPTIONS },
);
// 中文注释:素材库立即预览使用短期 signed URL;持久化仍记录 legacy path 和 objectKey。
@@ -28,6 +28,11 @@ import {
} from './editorProjectClient';
const requestJsonMock = vi.hoisted(() => vi.fn());
const editorRetryOptionsExpectation = expect.objectContaining({
maxRetries: 2,
retryUnsafeMethods: true,
retryableStatusCodes: expect.arrayContaining([429]),
});
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
@@ -585,7 +590,7 @@ describe('editorProjectClient', () => {
'生成图片失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -645,7 +650,7 @@ describe('editorProjectClient', () => {
'生成图片失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -708,7 +713,7 @@ describe('editorProjectClient', () => {
'生成图片失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -753,7 +758,7 @@ describe('editorProjectClient', () => {
'生成图标素材失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -807,7 +812,7 @@ describe('editorProjectClient', () => {
'生成图标素材失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -867,7 +872,7 @@ describe('editorProjectClient', () => {
'提取UI设计图素材失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -908,7 +913,7 @@ describe('editorProjectClient', () => {
'生成图片失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -953,7 +958,7 @@ describe('editorProjectClient', () => {
'生成图片失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -1012,7 +1017,7 @@ describe('editorProjectClient', () => {
'生成角色动画失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -1062,7 +1067,7 @@ describe('editorProjectClient', () => {
'生成视频失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -1162,7 +1167,7 @@ describe('editorProjectClient', () => {
'生成游戏音效失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -1234,7 +1239,7 @@ describe('editorProjectClient', () => {
'生成游戏背景音乐失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -1293,7 +1298,7 @@ describe('editorProjectClient', () => {
'修改图片失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
retry: editorRetryOptionsExpectation,
}),
);
});
@@ -1,4 +1,5 @@
import { requestJson } from '../apiClient';
import { EDITOR_REQUEST_RETRY_OPTIONS } from './editorRetryOptions';
const EDITOR_PROJECT_API_BASE = '/api/editor/projects';
const EDITOR_ASSET_API_BASE = '/api/editor/assets';
@@ -676,9 +677,7 @@ export async function generateEditorImage(input: EditorImageGenerationInput) {
'生成图片失败',
{
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
retry: EDITOR_REQUEST_RETRY_OPTIONS,
},
);
}
@@ -703,9 +702,7 @@ export async function generateEditorIconSpritesheet(
'生成图标素材失败',
{
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
retry: EDITOR_REQUEST_RETRY_OPTIONS,
},
);
}
@@ -731,9 +728,7 @@ export async function extractEditorUiDesignAssets(
'提取UI设计图素材失败',
{
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
retry: EDITOR_REQUEST_RETRY_OPTIONS,
},
);
}
@@ -760,9 +755,7 @@ export async function editEditorImage(input: EditorImageEditInput) {
'修改图片失败',
{
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
retry: EDITOR_REQUEST_RETRY_OPTIONS,
},
);
}
@@ -776,9 +769,7 @@ export async function generateEditorCharacterAnimation(
'生成角色动画失败',
{
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
retry: EDITOR_REQUEST_RETRY_OPTIONS,
},
);
}
@@ -808,9 +799,7 @@ export async function generateEditorVideo(input: EditorVideoGenerationInput) {
'生成视频失败',
{
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
retry: EDITOR_REQUEST_RETRY_OPTIONS,
},
);
}
@@ -828,9 +817,7 @@ export async function generateEditorSoundEffect(
'生成游戏音效失败',
{
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
retry: EDITOR_REQUEST_RETRY_OPTIONS,
},
);
}
@@ -847,9 +834,7 @@ export async function generateEditorBackgroundMusic(
'生成游戏背景音乐失败',
{
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
retry: EDITOR_REQUEST_RETRY_OPTIONS,
},
);
}
@@ -14,6 +14,11 @@ vi.mock('../assetReadUrlService', () => ({
const requestJsonMock = vi.mocked(requestJson);
const getSignedAssetReadUrlMock = vi.mocked(getSignedAssetReadUrl);
const editorRetryOptionsExpectation = expect.objectContaining({
maxRetries: 2,
retryUnsafeMethods: true,
retryableStatusCodes: expect.arrayContaining([429]),
});
describe('editorReferenceUploadClient', () => {
beforeEach(() => {
@@ -73,6 +78,7 @@ describe('editorReferenceUploadClient', () => {
body: expect.any(String),
}),
'创建参考素材上传凭证失败',
expect.objectContaining({ retry: editorRetryOptionsExpectation }),
);
const ticketBody = JSON.parse(
(requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string,
@@ -111,6 +117,7 @@ describe('editorReferenceUploadClient', () => {
body: expect.any(String),
}),
'确认参考素材资产失败',
expect.objectContaining({ retry: editorRetryOptionsExpectation }),
);
const confirmBody = JSON.parse(
(requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string,
@@ -1,5 +1,9 @@
import { requestJson } from '../apiClient';
import { getSignedAssetReadUrl } from '../assetReadUrlService';
import {
EDITOR_REQUEST_RETRY_OPTIONS,
postEditorDirectUploadFile,
} from './editorRetryOptions';
type EditorSeedanceReferenceMediaType = 'video' | 'audio';
@@ -48,28 +52,6 @@ function sanitizeReferenceFileName(fileName: string, mediaType: EditorSeedanceRe
return mediaType === 'video' ? 'seedance-reference.mp4' : 'seedance-reference.mp3';
}
async function postDirectUploadFile(
upload: DirectUploadTicketResponse['upload'],
file: File,
) {
const formData = new FormData();
Object.entries(upload.formFields).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
formData.append('file', file, file.name);
const response = await fetch(upload.host, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传参考素材失败');
}
}
export async function uploadEditorSeedanceReferenceFile(
file: File,
mediaType: EditorSeedanceReferenceMediaType,
@@ -98,9 +80,10 @@ export async function uploadEditorSeedanceReferenceFile(
}),
},
'创建参考素材上传凭证失败',
{ retry: EDITOR_REQUEST_RETRY_OPTIONS },
);
await postDirectUploadFile(ticket.upload, file);
await postEditorDirectUploadFile(ticket.upload, file, '上传参考素材失败');
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
'/api/assets/objects/confirm',
@@ -118,6 +101,7 @@ export async function uploadEditorSeedanceReferenceFile(
}),
},
'确认参考素材资产失败',
{ retry: EDITOR_REQUEST_RETRY_OPTIONS },
);
// 中文注释:返回短期 signed URL 供前端立即预览;生成请求最终优先提交 objectKey,由后端统一重新签名。
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { postEditorDirectUploadFile } from './editorRetryOptions';
describe('editorRetryOptions', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('retries direct editor uploads when the storage endpoint returns 429', async () => {
vi.useFakeTimers();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 429 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const uploadPromise = postEditorDirectUploadFile(
{
host: 'https://oss.example.com',
formFields: { key: 'editor/demo.mp4', policy: 'policy' },
},
new File(['video'], 'demo.mp4', { type: 'video/mp4' }),
'上传素材失败',
);
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(800);
await uploadPromise;
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenLastCalledWith(
'https://oss.example.com',
expect.objectContaining({
method: 'POST',
body: expect.any(FormData),
}),
);
});
});
@@ -0,0 +1,76 @@
import type { ApiRetryOptions } from '../apiClient';
const EDITOR_RETRYABLE_STATUS_CODES = [408, 425, 429, 502, 503, 504];
const EDITOR_RETRY_MAX_RETRIES = 2;
const EDITOR_RETRY_BASE_DELAY_MS = 800;
const EDITOR_RETRY_MAX_DELAY_MS = 3000;
export const EDITOR_REQUEST_RETRY_OPTIONS = {
maxRetries: EDITOR_RETRY_MAX_RETRIES,
baseDelayMs: EDITOR_RETRY_BASE_DELAY_MS,
maxDelayMs: EDITOR_RETRY_MAX_DELAY_MS,
retryUnsafeMethods: true,
retryableStatusCodes: EDITOR_RETRYABLE_STATUS_CODES,
} satisfies ApiRetryOptions;
type EditorDirectUploadTarget = {
host: string;
formFields: Record<string, string | null | undefined>;
};
function buildRetryDelayMs(attempt: number) {
return Math.min(
EDITOR_RETRY_MAX_DELAY_MS,
EDITOR_RETRY_BASE_DELAY_MS * Math.max(1, attempt),
);
}
function waitForRetryDelay(attempt: number) {
return new Promise((resolve) => {
setTimeout(resolve, buildRetryDelayMs(attempt));
});
}
function isRetryableEditorUploadStatus(status: number) {
return EDITOR_RETRYABLE_STATUS_CODES.includes(status);
}
function buildDirectUploadFormData(
upload: EditorDirectUploadTarget,
file: File,
) {
const formData = new FormData();
Object.entries(upload.formFields).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
formData.append('file', file, file.name);
return formData;
}
export async function postEditorDirectUploadFile(
upload: EditorDirectUploadTarget,
file: File,
errorMessage: string,
) {
for (let attempt = 0; ; attempt += 1) {
const response = await fetch(upload.host, {
method: 'POST',
body: buildDirectUploadFormData(upload, file),
});
if (response.ok) {
return;
}
if (
attempt >= EDITOR_RETRY_MAX_RETRIES ||
!isRetryableEditorUploadStatus(response.status)
) {
throw new Error(errorMessage);
}
await waitForRetryDelay(attempt + 1);
}
}