From faf3a2dbb204df4c82c7332dcd1248c19e92001f Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 7 Sep 2026 13:14:13 +0800 Subject: [PATCH] fix: retry vip98k reconciliation responses --- lib/ai/providers/vip98k.ts | 40 +++++++++++++++---- lib/ai/vip98k-reference-staging.ts | 22 ++++++++++ tests/routes/vip98k-reference-routing.test.ts | 29 ++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index 992b760e3..c7e9b1948 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -15,6 +15,7 @@ import { addVip98kReferenceRetryToken, getStorageObjectKeyFromPublicUrl, getVip98kStagedMediaRetryDelay, + getVip98kSubmissionRetryDelay, shouldStageVip98kReferenceUrl, } from "@/lib/ai/vip98k-reference-staging"; import { createLogger } from "@/lib/observability/logger"; @@ -67,6 +68,7 @@ const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000; const MAX_REFERENCE_DOWNLOAD_BYTES = 40 * 1024 * 1024; const REFERENCE_STAGING_CACHE_MS = 60_000; const REFERENCE_STAGING_KEY_PREFIX = "uploads/vip98k-"; +const MAX_SUBMISSION_ATTEMPTS = 3; type ReferenceStagingConfig = { sourcePublicUrl: string; @@ -807,11 +809,12 @@ async function submitTask(params: { typeof value === "string" && isStagedReferenceUrl(value), ) : []; - const maxAttempts = stagedReferences.length > 0 ? 3 : 1; + const maxAttempts = MAX_SUBMISSION_ATTEMPTS; + let refreshStagedReferences = false; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const requestBody = - attempt === 1 || !Array.isArray(params.body.file_urls) + !refreshStagedReferences || !Array.isArray(params.body.file_urls) ? params.body : { ...params.body, @@ -844,20 +847,41 @@ async function submitTask(params: { } const errorMessage = extractErrorMessage(payload); - const retryDelayMs = getVip98kStagedMediaRetryDelay({ + const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({ status: response.status, message: errorMessage, attempt, maxAttempts, stagedReferenceCount: stagedReferences.length, }); - if (retryDelayMs !== null) { + if (stagedMediaRetryDelayMs !== null) { logger.warn("staged reference not ready for provider; retrying submission", { attempt, - retryDelayMs, + retryDelayMs: stagedMediaRetryDelayMs, stagedReferenceCount: stagedReferences.length, }); - await delay(retryDelayMs); + refreshStagedReferences = true; + await delay(stagedMediaRetryDelayMs); + continue; + } + + const retryAfterSeconds = Number(response.headers.get("retry-after")); + const reconciliationRetryDelayMs = getVip98kSubmissionRetryDelay({ + status: response.status, + message: errorMessage, + attempt, + maxAttempts, + retryAfterMs: Number.isFinite(retryAfterSeconds) + ? retryAfterSeconds * 1000 + : null, + }); + if (reconciliationRetryDelayMs !== null) { + logger.warn("provider is reconciling task submission; retrying", { + attempt, + retryDelayMs: reconciliationRetryDelayMs, + }); + refreshStagedReferences = false; + await delay(reconciliationRetryDelayMs); continue; } throw new Error(`${errorMessage} (HTTP ${response.status})`); @@ -1312,7 +1336,9 @@ export function createVip98k({ apiKey, baseURL }: Vip98kConfig) { const activeModelId = String(options.overrideModelId || modelId || "").trim(); if (!activeModelId) throw new Error("VIP98K model ID is not configured"); const activeKind = inferKind(activeModelId, runtimeConfig); - const { prompt, imageUrls, videoUrls } = extractPromptAndMedia(options.messages); + const { prompt, imageUrls, videoUrls } = extractPromptAndMedia( + options.messages, + ); const [publicImageUrls, publicVideoUrls] = await Promise.all([ Promise.all( imageUrls.map((url) => ensurePublicMediaUrl(url, "image")), diff --git a/lib/ai/vip98k-reference-staging.ts b/lib/ai/vip98k-reference-staging.ts index ab96b3aaa..7e06d93d5 100644 --- a/lib/ai/vip98k-reference-staging.ts +++ b/lib/ai/vip98k-reference-staging.ts @@ -70,6 +70,28 @@ export function getVip98kStagedMediaRetryDelay(params: { return params.attempt * 20_000; } +export function getVip98kSubmissionRetryDelay(params: { + status: number; + message: string; + attempt: number; + maxAttempts: number; + retryAfterMs?: number | null; +}) { + if ( + params.status !== 503 || + params.attempt >= params.maxAttempts || + !/pending\s+safe\s+reconciliation/i.test(params.message) + ) { + return null; + } + + const providerDelay = Number(params.retryAfterMs); + if (Number.isFinite(providerDelay) && providerDelay > 0) { + return Math.min(Math.max(providerDelay, 1_000), 60_000); + } + return Math.min(params.attempt * 15_000, 30_000); +} + export function addVip98kReferenceRetryToken(mediaUrl: string, token: string) { const parsed = parseUrl(mediaUrl); if (!parsed) return mediaUrl; diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 7269b1df8..99083ad04 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -13,6 +13,7 @@ import { addVip98kReferenceRetryToken, getStorageObjectKeyFromPublicUrl, getVip98kStagedMediaRetryDelay, + getVip98kSubmissionRetryDelay, shouldStageVip98kReferenceUrl, } from "@/lib/ai/vip98k-reference-staging"; import { shouldResolveVip98kResultDelivery } from "@/lib/ai/vip98k-result-delivery"; @@ -88,6 +89,34 @@ test.describe("VIP98K reference-image routing", () => { ); }); + test("retries provider reconciliation without treating it as a media failure", () => { + expect( + getVip98kSubmissionRetryDelay({ + status: 503, + message: "The media request is pending safe reconciliation.", + attempt: 1, + maxAttempts: 3, + }), + ).toBe(15_000); + expect( + getVip98kSubmissionRetryDelay({ + status: 503, + message: "pending safe reconciliation", + attempt: 2, + maxAttempts: 3, + retryAfterMs: 8_000, + }), + ).toBe(8_000); + expect( + getVip98kSubmissionRetryDelay({ + status: 503, + message: "pending safe reconciliation", + attempt: 3, + maxAttempts: 3, + }), + ).toBeNull(); + }); + test("shows the newly published Nano Banana Pro image editor", () => { expect( isVip98kImageModelVisible({