Package and return results
Return worker output inline, as a base64 archive, or through an optional S3-compatible transport.
package_results_entry() combines the worker-owned metadata with one transport-specific payload. It returns one dictionary that a handler can place inside its own response envelope.
from pathlib import Path
from runpod_doc_worker.contract.artifacts import Artifact
from runpod_doc_worker.transport.package import package_results_entry
MANIFEST = (
Artifact("markdown", ("{basename}.md",), kind="text"),
)
entry = package_results_entry(
transport="inline",
formats=["markdown"],
output_dir=Path("/tmp/job-output"),
basename="document",
source="b64",
manifest=MANIFEST,
metadata={"pages_requested": 1},
)The package owns the basename, source and degraded fields. Metadata and manifest keys cannot replace them.
Choose a transport
| Transport | Returned payload | Use when |
|---|---|---|
inline | Selected manifest values | Results are small and useful as JSON fields |
tarball_b64 | Base64 archive in tarball_b64 | The caller needs every output file in one response |
s3 | Presigned URL and object details | The archive should stay outside the RunPod response body |
Transport names are case-sensitive. An unsupported value raises ValueError.
Inline results
For inline, formats selects manifest keys. A filtered key is omitted, while a requested missing artifact uses its default.
entry = package_results_entry(
transport="inline",
formats=["markdown", "blocks"],
output_dir=output_dir,
basename=basename,
source=source,
manifest=MANIFEST,
)Archive results
tarball_b64 and s3 package every accepted file below output_dir. The formats argument does not filter archive members. Every manifest artifact marked required must select a file that is accepted into the archive, can be read, and validates as its declared text or JSON kind. Validation uses the exact bytes written to the archive, before S3 upload; formats cannot filter that requirement.
Set archive_format="zip" for ZIP. Any other value currently falls back to the default gzip tar container, so validate a caller-supplied format before calling the package.
The final archive is assembled in memory. Source members are staged one at a time, and a member larger than 8 MiB spills to temporary disk before it is committed. tarball_b64 also pays base64 response overhead, which makes it a poor fit for large output directories.
Files that resolve outside the output directory, files the filesystem cannot place, and files whose names are unsafe to extract are skipped rather than archived, and each skip appears in the entry's degradation report. If one of those files satisfied a required artifact, packaging raises instead of returning an archive without it. An in-tree symlink is stored as its target bytes rather than as a link.
Degraded responses
Packaging keeps a job alive through a broken output directory: an unreadable optional artifact falls back to its default, an unreadable collection member is left out of the map, and an optional archive member that cannot be read or safely named is left out of the archive. One corrupt optional file should not cost a caller the intact ones.
That trade only works if the caller knows it happened, so anything lost appears under degraded — and only then. A response that lost nothing does not carry the key:
{
"basename": "report",
"source": "url:https://example.com/report.pdf",
"markdown": "",
"degraded": {
"count": 1,
"items": [
{"artifact": "markdown", "file": "report.md", "reason": "unreadable",
"error_type": "UnicodeDecodeError"}
]
}
}count is the true total. items stops at 50, so count above len(items) means the list is abridged rather than complete.
| Field | Meaning |
|---|---|
artifact | Manifest key this cost the response, or null for an archive member, which is not resolved through the manifest |
file | Filename, never a path — the output directory is a temp path on a worker and means nothing to a caller |
reason | unreadable (present but could not be read), unresolvable (the filesystem would not say what or where it is — a symlink loop, a link to nothing, a permission denial), outside_output_dir, or unsafe_name |
error_type | Exception class name, when an exception revealed the problem |
reason values are stable and bounded, which makes them usable as a metric label or a branch in a caller's retry logic. Treat a degraded entry as a document to requeue: the response is otherwise successful, so nothing else will flag it.
Every item is also written to stdout as a response degraded warning carrying the same fields. One message covers every drop site, present and future, so a log-based alert needs one rule rather than one per case. The response field is the per-document signal; the log line is the aggregate one.
To decide the outcome yourself rather than shipping a hole, mark the artifact required.
The response-size cap
RunPod's gateway caps a response at 20 MB on /runsync and 10 MB on /run. Over
that it does not report an error: the job comes back COMPLETED with a normal
executionTime and no output key at all, so the caller receives None and
nothing in the reply names size as the cause.
runpod_doc_worker.transport.response_size can refuse such a response instead, so
the caller gets an explanation rather than silence. It is off by default:
adopting a release that contains it does not change what an existing worker
returns.
from runpod_doc_worker.transport import response_size
response_size.ENFORCE_RESPONSE_CAP = True # opt in
response_size.BULKY_ARTIFACT = "middle.json" # your heaviest optional output| Name | Default | What it does |
|---|---|---|
ENFORCE_RESPONSE_CAP | False | Whether an undeliverable response is refused at all |
BULKY_ARTIFACT | None | Named in the refusal, so it can suggest the smallest formats change that would fit |
MAX_RESPONSE_MB | 20 | The ceiling. Raise it to disable the refusal on a deployment that is not behind the gateway |
With it on, package_results_entry raises
response_size.ResponseTooLargeError. A handler that already turns an exception
into ok=false therefore reports the size and the way out with no further wiring:
the output is 24.3 MB, past the 18.0 MB this transport can return (RunPod caps a
response at 20 MB on /runsync and half that on /run).
...
Any of these returns the output:
* transport="inline" with a formats list that leaves out middle.json;
* transport="s3", which returns a presigned URL instead of the bytes (needs the
BUCKET_* env vars on this endpoint, and the worker installed with the s3
extra -- the upload imports boto3);
* a bounded start_page / end_page range, the only option that keeps every format.Multi-file jobs
The gateway measures the whole body, so a job returning several entries has to measure the whole body too — two entries of 11 MB each pass individually and the 22 MB response they form is still discarded. Pass one budget across them:
budget = response_size.ResponseBudget(transport=transport)
for source in sources:
entries.append(package.package_results_entry(..., budget=budget))One budget per response, never a module-level one: a worker with a concurrency
modifier runs several jobs in one process, and a shared counter would refuse
whichever job happened to arrive last. ResponseBudget also starts partly spent,
for the envelope the handler adds around the entries; pass envelope= if you have
measured yours.
How the size is measured
Not by serialising the entry — json.dumps on a 20 MB payload allocates a second
copy of it. The entry is walked and its strings counted at their serialised
length, which matters more than it sounds: the SDK serialises with
ensure_ascii=True, so every non-ASCII character becomes a six-byte \uXXXX
escape. A Chinese or Russian parse is more than twice its UTF-8 size once encoded.
The remaining unknown is the envelope, absorbed by a per-transport margin: 97% of
the cap for tarball_b64, where the base64 string is the payload, and 90% for
inline. s3 is exempt, since its response is a presigned URL whatever the output
weighed.
S3 configuration
Install the s3 extra and set these variables on the endpoint:
| Variable | Required | Purpose |
|---|---|---|
BUCKET_ENDPOINT_URL | Yes | S3 API endpoint |
BUCKET_NAME | Yes | Destination bucket |
BUCKET_ACCESS_KEY_ID | Yes | Access key ID |
BUCKET_SECRET_ACCESS_KEY | Yes | Secret access key |
BUCKET_REGION | No | Provider region |
BUCKET_PREFIX | No | Prefix added before the generated object key |
BUCKET_PRESIGN_TTL_SECONDS | No | Presigned URL lifetime |
The URL lifetime defaults to 3,600 seconds and is clamped to 60 through 604,800 seconds. An unparseable value falls back to the default.
An S3 result contains:
{
"tarball_url": "https://storage.example/presigned-value",
"tarball_url_expires_in": 3600,
"bucket_key": "document-a1b2c3d4.tar.gz",
"bucket_bytes": 12345
}The object key includes a UUID to avoid collisions. The package does not delete uploaded objects or configure bucket retention.
Treat presigned URLs as credentials
Anyone with a presigned URL can download the object until it expires. Do not put that URL in logs, traces, or public error messages.
See artifact manifests for inline extraction rules and the security model for output boundaries.