From 6b90b25d5cf315feef0c82cd675061462b169639 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 9 Jun 2026 13:32:24 +0800 Subject: [PATCH] fix(worldlabs): increase world 3d api request timeouts --- .env.example | 3 + app/api/canvas/world/tasks/[taskId]/route.ts | 13 +++- app/api/canvas/world/tasks/route.ts | 12 ++- lib/worldlabs/client.ts | 78 ++++++++++++++++---- 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 019ac065d..f4b956304 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,9 @@ WORLDLABS_API_KEY= WORLDLABS_BASE_URL= WORLDLABS_IMPORT_TOKEN= WORLDLABS_KEY_POOL_IMPORT_TOKEN= +WORLDLABS_API_TIMEOUT_MS=180000 +WORLDLABS_TASK_CREATE_TIMEOUT_MS=180000 +WORLDLABS_TASK_STATUS_TIMEOUT_MS=180000 KITTYCAD_API_TOKEN= KITTYCAD_API_KEY= KITTYCAD_BASE_URL=https://api.zoo.dev diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index 5ebc876e4..54c57d3a0 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -16,9 +16,18 @@ import type { } from "@/lib/worldlabs/types"; export const runtime = "nodejs"; -export const maxDuration = 120; +export const maxDuration = 180; const logger = createLogger("world-labs-task-status"); +const WORLDLABS_TASK_STATUS_TIMEOUT_MS = + Number.parseInt(String(process.env.WORLDLABS_TASK_STATUS_TIMEOUT_MS || "").trim(), 10) || + 180_000; + +function resolveWorldLabsStatusTimeoutMs() { + if (!Number.isFinite(WORLDLABS_TASK_STATUS_TIMEOUT_MS)) return 180_000; + if (WORLDLABS_TASK_STATUS_TIMEOUT_MS <= 0) return 180_000; + return WORLDLABS_TASK_STATUS_TIMEOUT_MS; +} type ArchiveCacheItem = { sourceUrl: string; @@ -446,6 +455,7 @@ export async function GET(request: Request, { params }: RouteParams) { const operation = await worldLabsRequest(config, { method: "GET", path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`, + timeoutMs: resolveWorldLabsStatusTimeoutMs(), }); if (!operation.done) { @@ -505,6 +515,7 @@ export async function GET(request: Request, { params }: RouteParams) { const detailedWorld = await worldLabsRequest(config, { method: "GET", path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`, + timeoutMs: resolveWorldLabsStatusTimeoutMs(), }); world = world ? { diff --git a/app/api/canvas/world/tasks/route.ts b/app/api/canvas/world/tasks/route.ts index 857a7a997..4a123b15e 100644 --- a/app/api/canvas/world/tasks/route.ts +++ b/app/api/canvas/world/tasks/route.ts @@ -21,9 +21,18 @@ import { import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types"; export const runtime = "nodejs"; -export const maxDuration = 60; +export const maxDuration = 180; const logger = createLogger("world-labs-task-create"); +const WORLDLABS_TASK_CREATE_TIMEOUT_MS = + Number.parseInt(String(process.env.WORLDLABS_TASK_CREATE_TIMEOUT_MS || "").trim(), 10) || + 180_000; + +function resolveWorldLabsCreateTimeoutMs() { + if (!Number.isFinite(WORLDLABS_TASK_CREATE_TIMEOUT_MS)) return 180_000; + if (WORLDLABS_TASK_CREATE_TIMEOUT_MS <= 0) return 180_000; + return WORLDLABS_TASK_CREATE_TIMEOUT_MS; +} type WorldTaskMode = "text" | "image" | "multi-image" | "video"; @@ -236,6 +245,7 @@ export async function POST(request: Request) { result = await worldLabsRequest(candidate, { method: "POST", path: "/marble/v1/worlds:generate", + timeoutMs: resolveWorldLabsCreateTimeoutMs(), json: { display_name: displayName, model: resolvedWorldModel || "marble-1.1", diff --git a/lib/worldlabs/client.ts b/lib/worldlabs/client.ts index ce0c6cc0f..be420e74a 100644 --- a/lib/worldlabs/client.ts +++ b/lib/worldlabs/client.ts @@ -7,9 +7,43 @@ type WorldLabsRequestOptions = { path: string; json?: Record; headers?: Record; + timeoutMs?: number; }; const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai"; +const DEFAULT_WORLDLABS_REQUEST_TIMEOUT_MS = 180_000; + +function toIntMs(value: string | undefined) { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +function getDefaultWorldLabsTimeoutMs() { + return toIntMs(process.env.WORLDLABS_API_TIMEOUT_MS) || DEFAULT_WORLDLABS_REQUEST_TIMEOUT_MS; +} + +function createRequestSignal(timeoutMs: number) { + const timeoutSignal = (globalThis as Record).AbortSignal; + if (timeoutSignal && typeof (timeoutSignal as typeof AbortSignal).timeout === "function") { + return { + signal: (timeoutSignal as typeof AbortSignal).timeout(timeoutMs), + cancel: () => undefined, + }; + } + + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(`World Labs 请求超时 (${timeoutMs}ms)`); + }, timeoutMs); + + return { + signal: controller.signal, + cancel: () => clearTimeout(timer), + }; +} export class WorldLabsApiError extends Error { httpStatus: number; @@ -97,21 +131,37 @@ export async function worldLabsRequest( body = JSON.stringify(options.json); } - const response = await fetch(url, { - method: options.method || "GET", - headers, - body, - cache: "no-store", - }); - const parsed = await parseResponseBody(response); + const timeoutMs = options.timeoutMs || getDefaultWorldLabsTimeoutMs(); + const { signal, cancel } = createRequestSignal(timeoutMs); - if (!response.ok) { - throw new WorldLabsApiError({ - message: extractErrorMessage(parsed, `World Labs 请求失败(HTTP ${response.status})`), - httpStatus: response.status, - payload: parsed, + try { + const response = await fetch(url, { + method: options.method || "GET", + headers, + body, + cache: "no-store", + signal, }); - } + const parsed = await parseResponseBody(response); - return parsed as T; + if (!response.ok) { + throw new WorldLabsApiError({ + message: extractErrorMessage(parsed, `World Labs 请求失败(HTTP ${response.status})`), + httpStatus: response.status, + payload: parsed, + }); + } + + return parsed as T; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new WorldLabsApiError({ + message: `World Labs 请求超时(${timeoutMs}ms)`, + httpStatus: 504, + }); + } + throw error; + } finally { + cancel(); + } }