---
audience: developers
summary: When finalize fails, jobs sit pending, or presigned URLs misbehave.
title: Rendering + jobs
path: troubleshooting/rendering-and-jobs
status: published
---


# Rendering + jobs troubleshooting

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:

```python
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`

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

```python
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
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
# 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`

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

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
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
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
job_envelope = client.finalize(doc_id, formats=["pdf"], sync=False)
```

## 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](/docs/scaiscribe/troubleshooting/getting-started) — auth, validation, quota errors.
- [Troubleshooting → Ingestion](/docs/scaiscribe/troubleshooting/ingestion) — ingest-specific issues.
- [Reference → API](/docs/scaiscribe/reference/api) — finalize/preview/jobs shapes.
