为图片编辑器上传和生成加自动重试
新增图片编辑器请求重试配置 生成请求遇到429等临时错误自动重试 上传凭证、资产确认和直传上传共用重试策略 补充图片编辑器重试参数和直传429测试
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user