Repository navigation
fix: timeout stuck video creation #1876
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -53,6 +53,26 @@ function extractTimeoutDelay(content: string): number | null { | |||||
| return null; | ||||||
| } | ||||||
|
|
||||||
| function extractProviderSpecificTimeoutDelay( | ||||||
| content: string, | ||||||
| provider: "obsidian" | "avalanche" | "vertex", | ||||||
| ): number | null { | ||||||
| const token = | ||||||
| provider === "vertex" | ||||||
| ? "TRIGGER_VERTEX_ONLY_TIMEOUT" | ||||||
| : provider === "avalanche" | ||||||
| ? "TRIGGER_AVALANCHE_ONLY_TIMEOUT" | ||||||
| : "TRIGGER_OBSIDIAN_ONLY_TIMEOUT"; | ||||||
| const match = content.match(new RegExp(`${token}_(\\d+)`)); | ||||||
| if (match) { | ||||||
| return parseInt(match[1], 10); | ||||||
| } | ||||||
| if (content.includes(token)) { | ||||||
| return 5000; | ||||||
| } | ||||||
| return null; | ||||||
| } | ||||||
|
|
||||||
| // Helper to extract a specific HTTP status code from message content | ||||||
| // e.g., "TRIGGER_STATUS_429" -> { statusCode: 429, errorResponse: {...} } | ||||||
| function extractStatusCodeTrigger( | ||||||
|
|
@@ -511,6 +531,10 @@ mockOpenAIServer.post("/v1/videos", async (c) => { | |||||
| ? await c.req.parseBody({ all: true }) | ||||||
| : await c.req.json(); | ||||||
| const prompt = typeof body.prompt === "string" ? body.prompt : ""; | ||||||
| const timeoutDelay = extractProviderSpecificTimeoutDelay(prompt, "obsidian"); | ||||||
| if (timeoutDelay) { | ||||||
| await delay(timeoutDelay); | ||||||
| } | ||||||
| const statusTrigger = extractStatusCodeTrigger(prompt); | ||||||
| if (statusTrigger) { | ||||||
| c.status(statusTrigger.statusCode as any); | ||||||
|
|
@@ -560,6 +584,10 @@ mockOpenAIServer.post("/v1/videos", async (c) => { | |||||
| mockOpenAIServer.post("/api/v1/veo/generate", async (c) => { | ||||||
| const body = await c.req.json(); | ||||||
| const prompt = typeof body.prompt === "string" ? body.prompt : ""; | ||||||
| const timeoutDelay = extractProviderSpecificTimeoutDelay(prompt, "avalanche"); | ||||||
| if (timeoutDelay) { | ||||||
|
||||||
| if (timeoutDelay) { | |
| if (typeof timeoutDelay === "number") { |
Copilot
AI
Mar 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
extractProviderSpecificTimeoutDelay returns number | null; using if (timeoutDelay) skips the delay for 0. Switch to an explicit null check so ..._0 behaves as intended and to avoid relying on truthiness.
| if (timeoutDelay) { | |
| if (timeoutDelay !== null) { |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,11 @@ import { | |
| findProviderKey, | ||
| } from "@/lib/cached-queries.js"; | ||
| import { validateModelAccess } from "@/lib/iam.js"; | ||
| import { | ||
| createVideoCreateTimeoutSignal, | ||
| getVideoCreateTimeoutMs, | ||
| isTimeoutError, | ||
| } from "@/lib/timeout-config.js"; | ||
|
|
||
| import { | ||
| getCheapestFromAvailableProviders, | ||
|
|
@@ -1958,7 +1963,30 @@ async function fetchUpstreamJson( | |
| url: string, | ||
| init: RequestInit, | ||
| ): Promise<Record<string, unknown>> { | ||
| const response = await fetch(url, init); | ||
| const timeoutSignal = createVideoCreateTimeoutSignal(); | ||
| const signal = init.signal | ||
| ? AbortSignal.any([init.signal, timeoutSignal]) | ||
| : timeoutSignal; | ||
| let response: Response; | ||
| try { | ||
| response = await fetch(url, { | ||
| ...init, | ||
| signal, | ||
| }); | ||
| } catch (error) { | ||
| if (isTimeoutError(error)) { | ||
| logger.warn("Upstream video create request timed out", { | ||
| url, | ||
| timeoutMs: getVideoCreateTimeoutMs(), | ||
| }); | ||
| throw new HTTPException(504, { | ||
|
Comment on lines
+1978
to
+1982
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new 504 here is treated as retryable by the outer loop in Useful? React with 👍 / 👎. |
||
| message: | ||
| "Video creation timed out while waiting for the upstream provider. Please try again.", | ||
| }); | ||
| } | ||
|
|
||
| throw error; | ||
| } | ||
| const text = await response.text(); | ||
| let body: Record<string, unknown> = {}; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,19 @@ | |
|
|
||
| export const maxDuration = 60; | ||
|
|
||
| function getVideoCreateProxyTimeoutMs(): number { | ||
| const envValue = Number(process.env.PLAYGROUND_VIDEO_CREATE_TIMEOUT_MS); | ||
| if (envValue > 0) { | ||
| return envValue; | ||
| } | ||
|
|
||
| return Math.max(1000, maxDuration * 1000 - 5000); | ||
|
Check failure on line 16 in apps/playground/src/app/api/video/route.ts
|
||
| } | ||
|
|
||
| function isTimeoutError(error: unknown): boolean { | ||
| return error instanceof Error && error.name === "TimeoutError"; | ||
| } | ||
|
|
||
| export async function POST(req: Request) { | ||
| const user = await getUser(); | ||
| if (!user) { | ||
|
|
@@ -31,16 +44,32 @@ | |
| const requestBody = await req.json(); | ||
| const noFallback = req.headers.get("x-no-fallback"); | ||
|
|
||
| const response = await fetch(`${gatewayBaseUrl}/v1/videos`, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${apiKey}`, | ||
| "x-source": "chat.llmgateway.io", | ||
| ...(noFallback ? { "x-no-fallback": noFallback } : {}), | ||
| }, | ||
| body: JSON.stringify(requestBody), | ||
| }); | ||
| let response: Response; | ||
| try { | ||
| response = await fetch(`${gatewayBaseUrl}/v1/videos`, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${apiKey}`, | ||
| "x-source": "chat.llmgateway.io", | ||
| ...(noFallback ? { "x-no-fallback": noFallback } : {}), | ||
| }, | ||
| body: JSON.stringify(requestBody), | ||
| signal: AbortSignal.timeout(getVideoCreateProxyTimeoutMs()), | ||
|
Comment on lines
+57
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This new timeout makes the playground return a 504 after ~55s, but the gateway video-create path never threads the incoming request abort signal into its upstream work (I checked Useful? React with 👍 / 👎. |
||
| }); | ||
| } catch (error) { | ||
| if (isTimeoutError(error)) { | ||
| return NextResponse.json( | ||
| { | ||
| error: | ||
| "Video creation timed out before the gateway responded. Please try again.", | ||
| }, | ||
| { status: 504 }, | ||
| ); | ||
| } | ||
|
|
||
| throw error; | ||
| } | ||
|
|
||
| const responseBody = await readGatewayResponseBody(response); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
extractProviderSpecificTimeoutDelaycan legitimately return0(e.g.,..._0), but the currentif (timeoutDelay)check treats0as falsy and skips the delay. Use an explicit null check (e.g.,timeoutDelay !== null) so the behavior matches the trigger value exactly.