Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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#

SymptomPOST /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:

python
1
2
job = client.admin.get_job(error.details["job_id"])
print(job.result_json)

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#

Symptomfinalize 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:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import time

job_id = response.json()["job_id"]
while True:
    job = client.get_job(job_id)
    if job.status in ("completed", "failed"):
        break
    time.sleep(1)

if job.status == "completed":
    artifact = job.result_json["outputs"][0]
    download_url = artifact["download_url"]
elif job.status == "failed":
    raise RuntimeError(job.result_json["message"])

For known-slow operations (large docs, many charts, ingestion), pass sync=False from the start to skip the inline wait:

python
1
2
job_envelope = client.finalize(doc_id, formats=["pdf"], sync=False)
# poll job_envelope.job_id

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:

  1. 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.
  2. Redis is unreachable from the worker. Check worker logs for connection errors.
  3. Worker crashed mid-job and the job's lease expired but no other worker is taking over (single-replica deployments).

Diagnose (operator):

bash
1
2
3
4
5
# Check worker processes
ps aux | grep arq | grep -v grep

# Check Redis queue depth
redis-cli -h <redis-host> LLEN arq:queue:scribe.gen

Fix — Restart whichever worker pool is missing. Re-submit the job (the original will eventually GC).

Job failed with error: null#

Symptomjob.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:

  1. 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 (or get_job for the same job_id) to get a fresh URL.
  2. 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:

python
1
2
job = client.get_job(job_id)
fresh_url = job.result_json["outputs"][0]["download_url"]

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:

python
1
2
3
4
5
6
try:
    result = client.finalize(doc_id, formats=["pdf"])
    # success path
except JobTimeoutError as e:
    job_envelope = e.job_envelope
    # poll job_envelope.job_id

Or proactively, for known-slow operations:

python
1
job_envelope = client.finalize(doc_id, formats=["pdf"], sync=False)

Preview screenshots — page count is 0 / pages don't render#

Symptompreview(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#

Updated 2026-07-03 13:00:10 View source (.md) rev 9