From 96d67955cbeb4f60ca1dba5e746907853dc1d0cd Mon Sep 17 00:00:00 2001 From: 134 Date: Sat, 3 Oct 2026 17:29:31 +0800 Subject: [PATCH] fix(canvas): preserve uncertain image submissions without paid failover --- lib/ai/providers/vip98k.ts | 7 ++++++- lib/ai/vip98k-reference-staging.ts | 3 ++- lib/canvas/generate-image-response.ts | 6 ++++-- lib/canvas/generate-image.ts | 4 ++++ lib/video-canvas/task-worker.ts | 1 + tests/routes/vip98k-reference-routing.test.ts | 6 ++++++ 6 files changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index b9e998393..f4c1b0a41 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -1,6 +1,7 @@ import { mapVip98kVideoParameters } from "../vip98k-video-parameters"; import { ProviderTaskFailedError, + ProviderTaskUncertainError, type ProviderTaskLifecycle, } from "@/lib/video-canvas/video-task-lifecycle"; import "server-only"; @@ -1113,7 +1114,7 @@ async function submitTask(params: { submissionTimeoutMs: params.submissionTimeoutMs, message: error instanceof Error ? error.message : String(error), }); - throw error; + throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${error instanceof Error ? error.message : String(error)}`); } const retryDelayMs = attempt * params.submissionRetryBaseDelayMs; logger.warn("provider submission transport failed; retrying idempotently", { @@ -1192,6 +1193,10 @@ async function submitTask(params: { await delay(reconciliationRetryDelayMs); continue; } + if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage)) + || [502, 504].includes(response.status) + || /pending\s+safe\s+reconciliation|request\s+outcome\s+(?:is\s+)?unknown|final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(errorMessage)) + throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${errorMessage} (HTTP ${response.status})`); throw new Error(`${errorMessage} (HTTP ${response.status})`); } diff --git a/lib/ai/vip98k-reference-staging.ts b/lib/ai/vip98k-reference-staging.ts index 645dd87ce..eb012bae1 100644 --- a/lib/ai/vip98k-reference-staging.ts +++ b/lib/ai/vip98k-reference-staging.ts @@ -100,7 +100,8 @@ export function getVip98kSubmissionRetryDelay(params: { maxAttempts: number; retryAfterMs?: number | null; }) { - const recoverableGatewayFailure = params.status === 502 || params.status === 504; + const inProgress = params.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(params.message); + const recoverableGatewayFailure = params.status === 502 || params.status === 504 || inProgress; const recoverableSubmission = /pending\s+safe\s+reconciliation/i.test(params.message) || /request\s+outcome\s+(?:is\s+)?unknown/i.test(params.message) || diff --git a/lib/canvas/generate-image-response.ts b/lib/canvas/generate-image-response.ts index 525484d04..0762ef2be 100644 --- a/lib/canvas/generate-image-response.ts +++ b/lib/canvas/generate-image-response.ts @@ -1,4 +1,5 @@ import "server-only"; +import { ProviderTaskUncertainError } from "@/lib/video-canvas/video-task-lifecycle"; import { NextResponse } from "next/server"; import { createCanvasImageTask, @@ -120,8 +121,9 @@ export async function executeCanvasImageResponse(body: GenerateImageRequest, use } catch (error) { console.error("[Canvas Generate Image] Error:", error); return NextResponse.json( - { success: false, error: getUserFacingImageGenerationErrorMessage(error) }, - { status: 500 }, + { success: false, error: getUserFacingImageGenerationErrorMessage(error), + ...(error instanceof ProviderTaskUncertainError ? { code: "PROVIDER_TASK_UNCERTAIN" } : {}) }, + { status: error instanceof ProviderTaskUncertainError ? 503 : 500 }, ); } } diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index a071b6fa3..07e505b23 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -1,4 +1,5 @@ import "server-only"; +import { ProviderTaskUncertainError } from "@/lib/video-canvas/video-task-lifecycle"; import sharp from "sharp"; import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; @@ -5623,6 +5624,9 @@ export async function generateCanvasImage( await slot.release(); } + // An accepted or uncertain request must not start another paid model/variant. + if (caughtError instanceof ProviderTaskUncertainError) throw caughtError; + const retryVariants = Array.isArray(request.retryVariants) ? request.retryVariants : []; diff --git a/lib/video-canvas/task-worker.ts b/lib/video-canvas/task-worker.ts index fed984c30..3c2897bc6 100644 --- a/lib/video-canvas/task-worker.ts +++ b/lib/video-canvas/task-worker.ts @@ -159,6 +159,7 @@ async function execute(task: CanvasGenerationTask, userId: string) { ) : await executeWorkflowAnalysisResponse(params); const body = await response.json(); + if (body.code === "PROVIDER_TASK_UNCERTAIN") throw new ProviderTaskUncertainError(body.error); if (!response.ok || !body.success) throw Error(body.error || "任务执行失败"); if ( request.kind === "image" && diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 7f47309d4..1fb52f2c8 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -232,6 +232,12 @@ test.describe("VIP98K reference-image routing", () => { }); test("retries provider reconciliation without treating it as a media failure", () => { + expect(getVip98kSubmissionRetryDelay({ status: 409, message: "A request with this idempotency key is still in progress.", + attempt: 1, maxAttempts: 3 })).toBe(15_000); + expect(getVip98kSubmissionRetryDelay({ status: 409, message: "Conflicting request parameters", + attempt: 1, maxAttempts: 3 })).toBeNull(); + expect(getVip98kSubmissionRetryDelay({ status: 409, message: "A request with this idempotency key is still in progress.", + attempt: 3, maxAttempts: 3 })).toBeNull(); expect( getVip98kSubmissionRetryDelay({ status: 503,