nexu-io/open-design · error · Error
openrouter video timed out after
Error message
openrouter video timed out after ${elapsedSec}s waiting for status=completed (last status: ${lastStatus || 'pending'}, ceiling ${ceilingSec}s). If your jobs legitimately need longer, raise OD_OPENROUTER_VIDEO_MAX_POLL_MS. What it means
Thrown by the OpenRouter video polling loop when the daemon never observed status=completed (and never collected any videoUrls) before OD_OPENROUTER_VIDEO_MAX_POLL_MS elapsed. It is a wall-clock ceiling guard, distinct from upstream failure errors: the job may still be running server-side when this fires. The message reports elapsed seconds, the last observed status, and the configured ceiling so an operator can decide whether to raise the limit.
Solutions
- Raise the ceiling: set OD_OPENROUTER_VIDEO_MAX_POLL_MS to a higher value (e.g. 1800000 for 30 min) in the daemon environment if your jobs legitimately run long.
- Shorten the requested video length or pick a faster OpenRouter video model so upstream finishes inside the default window.
- Check OpenRouter status/dashboard to confirm the job actually stalled vs. is still processing; retry the request if it was a transient queue backlog.
- If lastStatus shows a non-pending terminal value, inspect the upstream API contract — a status string the parser doesn't map to 'completed' will loop forever until timeout.
Example fix
// before // OD_OPENROUTER_VIDEO_MAX_POLL_MS unset → default ceiling // after (operator) export OD_OPENROUTER_VIDEO_MAX_POLL_MS=1800000 # restart the daemon so the env var is picked up
Defensive patterns
Strategy: retry
Validate before calling
// Before invoking renderOpenRouterVideo, sanity-check the configured ceiling
// against the requested job size so callers get a fast config error instead of a
// 10-minute timeout.
function assertOpenRouterCeilingOk(requestedLengthSec: number) {
const maxMs = Number(process.env.OD_OPENROUTER_VIDEO_MAX_POLL_MS) || 10 * 60 * 1000;
// OpenRouter video jobs scale roughly linearly with length; flag anything
// where the default ceiling is clearly too small.
const estimatedMs = requestedLengthSec * 60 * 1000; // ~1min/s, conservative
if (estimatedMs > maxMs) {
throw new Error(
`requested ${requestedLengthSec}s video likely exceeds OD_OPENROUTER_VIDEO_MAX_POLL_MS (${Math.round(maxMs / 1000)}s); raise the env var or shorten the request.`,
);
}
} Try / catch
// Catch the timeout specifically and surface the raise-the-ceiling hint to the user.
try {
await renderOpenRouterVideo(ctx, creds, onProgress);
} catch (e) {
const msg = String((e as Error).message || e);
if (msg.startsWith('openrouter video timed out')) {
// Surface the operator action; do NOT silently retry a job that already ran long.
throw new Error(`Video generation timed out. Ask the operator to raise OD_OPENROUTER_VIDEO_MAX_POLL_MS. Detail: ${msg}`);
}
throw e;
} Prevention
- Set OD_OPENROUTER_VIDEO_MAX_POLL_MS explicitly in the daemon environment based on your typical job length, do not rely on the default.
- Track p95 job duration per model in your observability and alert before it approaches the ceiling.
- Keep the requested video length modest; long single jobs are more fragile than concatenating shorter ones.
When it happens
Trigger: OpenRouter video generation (renderOpenRouterVideo) where the GET status endpoint keeps returning a non-complete status (PENDING/PROCESSING) for longer than maxMs. Triggered when the while-loop exits the poll window with videoUrls still empty and lastStatus never reaching 'completed'.
Common situations: Long video jobs (high duration, complex prompt) that legitimately exceed the default ceiling; upstream OpenRouter slowness or queueing; network stalls that stretch individual poll latency; default ceiling too low for the model/duration combination a user selected.
Understand the failure class
- Timeouts: ETIMEDOUT, deadlines, and hung requests — what actually expires when a request times out.
Related errors
- grok video timed out after
- leonardo.ai generation timed out after 2 minutes
- openrouter job
- openrouter poll non-JSON
- openrouter poll
AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12).
Data as JSON: /api/errors/8e1c4771d62be027.
Report an issue: GitHub.
Appendix: source
Thrown at apps/daemon/src/media/index.ts:2123
break;
}
if (
lastStatus === 'failed'
|| lastStatus === 'expired'
|| lastStatus === 'cancelled'
) {
const reasonRaw =
pollData?.error?.message || pollData?.error || lastStatus;
const reason =
typeof reasonRaw === 'string' ? reasonRaw : JSON.stringify(reasonRaw);
throw new Error(`openrouter job ${lastStatus}: ${reason}`);
}
}
if (!videoUrls || videoUrls.length === 0) {
const elapsedSec = Math.round((Date.now() - startedAt) / 1000);
const ceilingSec = Math.round(maxMs / 1000);
throw new Error(
`openrouter video timed out after ${elapsedSec}s waiting for status=completed `
+ `(last status: ${lastStatus || 'pending'}, ceiling ${ceilingSec}s). `
+ `If your jobs legitimately need longer, raise OD_OPENROUTER_VIDEO_MAX_POLL_MS.`,
);
}
// ── Step 3: Download the video binary ──────────────────────────────
// unsigned_urls are often third-party CDNs where sending our API key
// would leak credentials. However, sometimes OpenRouter returns a proxied
// openrouter.ai URL that still requires authorization. We only attach the
// auth header if the host is explicitly allowlisted as openrouter.ai.
const contentUrl = videoUrls[0]!;
const parsedContentUrl = new URL(contentUrl);
const dlHeaders: Record<string, string> = {};
if (parsedContentUrl.hostname === 'openrouter.ai') {
dlHeaders['authorization'] = `Bearer ${credentials.apiKey}`;
}View on GitHub (pinned to 5be4028344)