Rendering + jobs
This page covers the finalize/preview path and the underlying job queue: when renders fail, when sync timeouts trip, and how to handle the async pattern correctly.
500 RENDER_FAILED from finalize#
Symptom — POST /v1/documents/{id}/finalize returns a 500 with error.code = "RENDER_FAILED".
Cause — The worker raised during render. The error envelope's message carries the proximate cause; details.job_id lets you fetch the full result.
Diagnose — Pull the job:
1 2 | |
Common subcauses:
| Message contains | Real problem |
|---|---|
gotenberg returned 5xx |
Gotenberg unavailable / timed out. Operator: check SCAISCRIBE_GOTENBERG_URL, confirm the container is running, watch for OOM. |
LibreOffice conversion failed |
Gotenberg's LibreOffice path can't open the source. Most common cause: the .docx the renderer produced is structurally invalid (bug). Save the intermediate to disk via worker logs and inspect with unzip. |
ChartImageRenderError |
ECharts SSR failed for a chart. Usually unsupported kind + data shape combo. Fix the chart spec or set render_mode: "image" to use a different code path. |
node_runner: emitter render-docx.js exit 2 |
Renderer crashed. Check error.details.stderr for the Node-side stack trace. |
Job timed out (worker stopped responding) |
Worker process died or stalled. Operator: check worker logs and restart the pool. |
Render hangs and eventually returns 202 with job_id#
Symptom — finalize returns 202 instead of 200; the job stays in pending for a long time.
Cause — The sync-wrap window expired (default 15s for render, 30s for ingestion). The job is still running in the background — you got a job_id to poll.
Diagnose — Don't assume failure. Poll first:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
For known-slow operations (large docs, many charts, ingestion), pass sync=False from the start to skip the inline wait:
1 2 | |
Job stuck in pending forever#
Symptom — A job_id stays pending for minutes or hours without ever moving to running/completed/failed.
Cause — The dispatch wasn't picked up by a worker. Possibilities:
- No worker is running for that pool. The three pools are
scribe.gen(render_docx/pptx/xlsx, instantiate_template),scribe.render(render_pdf, render_html_preview, render_screenshots),scribe.ingest(ingest_docx/pptx/xlsx). All three must be running. - Redis is unreachable from the worker. Check worker logs for connection errors.
- Worker crashed mid-job and the job's lease expired but no other worker is taking over (single-replica deployments).
Diagnose (operator):
1 2 3 4 5 | |
Fix — Restart whichever worker pool is missing. Re-submit the job (the original will eventually GC).
Job failed with error: null#
Symptom — job.status = "failed", but job.error is null and job.result_json is null too.
Cause — The worker process died catastrophically (OOM kill, SIGKILL, kernel panic) before it could write its error state.
Fix — Resubmit. If it happens repeatedly with the same doc, suspect a worker-side bug; capture worker logs and file a report.
Presigned download URL returns 403 / SignatureDoesNotMatch#
Symptom — Your result.outputs[0].download_url returns 403 when you GET it.
Cause — Two main possibilities:
- URL expired. Presigned URLs are valid for ~1 hour. If you cached the URL and tried to fetch it later, it's expired. Re-call
finalize(orget_jobfor the same job_id) to get a fresh URL. - Storage backend mismatch. The URL signature is computed against a specific S3 endpoint. If you're consuming it from inside the network and the URL points at an external hostname (or vice versa), the host won't match. This is a deployment concern, not a code one.
Fix — Always fetch the URL immediately after rendering. Don't store it for later use — store the storage_key and re-presign when you need to fetch:
1 2 | |
Presigned download URL returns 200 but the file is corrupted / 0 bytes#
Symptom — The download succeeds (200) but the resulting file is empty or unparseable.
Cause — Almost always a race condition: the URL was issued before the worker finished writing the binary to object storage. With S3 backends that support strong read-after-write (modern AWS S3, Garage), this can't happen. With older eventually-consistent stores it occasionally did.
Fix — If you're on a modern S3-compatible backend (Garage, MinIO recent, AWS S3 post-2020), this shouldn't happen — file a bug. If you're on an older eventually-consistent store, add a retry-with-backoff at the client side (1s, 2s, 5s).
JobTimeoutError from SDK call#
Symptom — The SDK's finalize/ingest_document/preview raises JobTimeoutError after the sync wait.
Cause — The same 202 case above, but the SDK is raising rather than returning the envelope. Pass sync=False to get the envelope back as a normal return value:
1 2 3 4 5 6 | |
Or proactively, for known-slow operations:
1 | |
Preview screenshots — page count is 0 / pages don't render#
Symptom — preview(mode="screenshots") returns a 200 with page_count: 0 and empty pages[].
Cause — Either the source produced an empty PDF (nothing to rasterise), or pdftoppm from poppler-utils isn't installed on the worker host.
Fix — For empty PDF: check the source document has content. For missing pdftoppm: operator installs poppler-utils (apt install poppler-utils on Debian).
fast_html preview returns a screenshot-wrapped HTML for PPTX/XLSX#
Symptom — You expected mammoth-style HTML but got an HTML file wrapping page screenshots.
Cause — DOCX preview uses mammoth (semantic HTML). PPTX and XLSX go through Gotenberg PDF → per-page PNG → self-contained HTML wrapper. The body of the HTML is a series of <img> tags, not reflowable markup.
This is intentional — there's no general "PPTX/XLSX to semantic HTML" path of comparable quality. Screenshots-in-HTML is the best we can do without losing visual accuracy.
Fix — Not a bug, but if you specifically need reflowable HTML, you can do mammoth-DOCX preview against the same doc by rendering to .docx first, then preview(mode="fast_html"). Caveat: that path only handles DOCX source.
Worker pool restart drops in-flight jobs#
Symptom — After an operator restarts a worker pool, some jobs go from running to failed with error: "worker shut down".
Cause — arq doesn't redistribute in-flight work on shutdown; the job stays marked running until lease expires.
Fix (operator) — When restarting workers gracefully, wait for the existing pool to drain (no running jobs in arq:jobs:* status keys) before SIGKILLing. For immediate restarts, accept that in-flight jobs fail and rely on the caller to retry.
Where to next#
- Troubleshooting → Getting started — auth, validation, quota errors.
- Troubleshooting → Ingestion — ingest-specific issues.
- Reference → API — finalize/preview/jobs shapes.