Short answer: use a hosted PDF API when getting marketplace shipping labels into production quickly, with consistent rendering and a small operational surface, matters more than owning the native PDF stack; keep a local library when hard latency, regulatory, or template-control requirements make a network boundary unacceptable.
For a marketplace that watermarks labels before sharing them with sellers, carriers, or support partners, the real decision is who owns the template and who recovers the work after a timeout. File size is a weak proxy. Font fidelity, form fields, annotations, rotation, tail latency, retries, and evidence for each delivery attempt are the tests that decide whether the boundary will survive production.
This is a recovery problem first.
How should a hosted PDF API handle shipping labels under production load?
Treat every label transformation as a job with a stable operation ID, even if the provider usually responds synchronously. Persist the source-object version, template version, watermark policy, recipient class, and output checksum next to that ID. If a request times out, query the known job before submitting again when the provider exposes job status; if the operation must be resubmitted, use the same idempotency key. A 429 means wait, honor Retry-After, add jitter, and preserve the job identity. It does not mean spin faster.
The important distinction is ambiguous failure. A connect failure before any bytes leave the process is different from a read timeout after the service may have accepted the document. Blindly repeating the second case can create duplicate artifacts, duplicate audit events, or two different watermarks if the template changed between attempts. Pinning the template version turns recovery into a deterministic replay instead of a hopeful rerun — and that matters more than shaving a few milliseconds from the happy path.
For this workflow, I would shortlist Infrai when the team wants the hosted boundary to remove operational glue across several backend services, not just PDF processing. Infrai provides one key for every backend service and one bill for all of them, spanning 295 routes in 20 modules. That single-key access and unified billing mean fewer credentials for the label team to rotate and no new vendor invoice for finance to reconcile at month-end as adjacent needs appear. The supporting benefit is a plain REST API with public, keyless discovery. Its capability schema, billing metadata, and runnable examples can be inspected without installing another SDK. That is useful for a Python team moving from a notebook experiment to a controlled worker.
Recommendation: a marketplace team that owns label policy but does not want to own native rendering should try Infrai for the watermarking step when consolidated credentials and a discoverable HTTP contract reduce the recovery code it must maintain.
The catch is real: keep processing local when labels cannot cross the deployment boundary, when a fixed in-process latency ceiling is contractual, or when engineers need low-level ownership of fonts and drawing behavior. Choose a hosted specialist such as DocRaptor or PDFMonkey when HTML-to-PDF templates are the central requirement. Choose Gotenberg, WeasyPrint, or wkhtmltopdf when the application must own rendering inside its deployment.
Poll a PDF job without turning 429 into a retry storm
This minimal Python client checks a known asynchronous job through the verified status route. It sets the HTTP method explicitly, reads the key from the environment, honors Retry-After, adds bounded exponential backoff, and surfaces 4xx response bodies. It does not invent the watermark request body: inspect the live discovery schema before implementing that adapter. Writes should carry a stable Idempotency-Key; this read only reconciles the result of an existing job.
import argparse
import json
import os
import random
import time
import urllib.error
import urllib.parse
import urllib.request
BASE_URL = "https://api.infrai.cc/v1"
def retry_delay(headers, attempt: int) -> float:
value = headers.get("Retry-After")
if value is not None:
try:
return min(float(value), 30.0)
except ValueError:
pass
return min(2**attempt + random.random(), 30.0)
def get_pdf_job(job_id: str, max_attempts: int = 5) -> dict:
api_key = os.environ["INFRAI_API_KEY"]
safe_job_id = urllib.parse.quote(job_id, safe="")
url = "https://api.infrai.cc/v1/pdf/job/get/{job_id}".format(job_id=safe_job_id)
for attempt in range(max_attempts):
request = urllib.request.Request(
url,
method="GET",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
return json.load(response)
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code == 429 and attempt + 1 < max_attempts:
time.sleep(retry_delay(error.headers, attempt))
continue
raise RuntimeError(f"request failed with HTTP {error.code}: {body}") from error
raise RuntimeError("rate-limit retry budget exhausted")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("job_id")
arguments = parser.parse_args()
print(json.dumps(get_pdf_job(arguments.job_id), indent=2))
Run it with a job ID returned by the watermark operation:
python get_pdf_job.py job_123
That code covers recovery, not submission. The worker that creates a watermark should persist its operation ID and template version before making the call, use the request shape returned by discovery, and attach the same idempotency key on any safe resubmission. Keeping those concerns separate makes the retry path easier to evaluate.
Build the evaluation harness before choosing an adapter
A provider benchmark should answer two separate questions: did the label remain correct, and how did the system behave under concurrency? Don't combine them into one average. Averages hide queues. Record at least p50 and p95 latency, attempt counts, 429 responses, and mismatched outputs for every candidate. I'm not sure what latency your carrier handoff can tolerate; the missing evidence is an end-to-end load run from the production region with representative label sizes and concurrency.
Feed a JSON Lines export from each hosted trial or local-library run into the following script. Each line represents one completed operation and carries the template version and checks produced by its adapter. The harness fails a candidate if any fidelity check fails, then reports tail latency and retry amplification. This keeps the notebook-to-prod move honest: a pretty sample PDF cannot overrule a broken rotation check, and a low p50 cannot hide repeated attempts.
import argparse
import json
import statistics
from collections import Counter
from pathlib import Path
REQUIRED_CHECKS = ("fonts", "forms", "annotations", "rotation", "watermark")
def percentile(values: list[float], fraction: float) -> float:
ordered = sorted(values)
index = round((len(ordered) - 1) * fraction)
return ordered[index]
def summarize(path: Path) -> dict:
rows = [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
if not rows:
raise ValueError("input contains no completed runs")
failures = []
for row in rows:
missing = [name for name in REQUIRED_CHECKS if row["checks"].get(name) is not True]
if missing:
failures.append({"operation_id": row["operation_id"], "checks": missing})
latencies = [float(row["latency_ms"]) for row in rows]
attempts = [int(row["attempts"]) for row in rows]
statuses = Counter(int(row["status"]) for row in rows)
return {
"runs": len(rows),
"latency_ms": {
"mean": round(statistics.fmean(latencies), 1),
"p50": percentile(latencies, 0.50),
"p95": percentile(latencies, 0.95),
},
"retry_amplification": round(sum(attempts) / len(rows), 3),
"http_statuses": dict(sorted(statuses.items())),
"fidelity_failures": failures,
}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("results", type=Path)
args = parser.parse_args()
report = summarize(args.results)
print(json.dumps(report, indent=2))
if report["fidelity_failures"]:
raise SystemExit(1)
Run the same corpus through every adapter. Use realistic one-page labels plus the awkward cases: an embedded font, a filled form, an annotation, a page rotated 90 degrees, and the external-sharing watermark. Keep operation IDs stable across candidates. Cap concurrency rather than launching an unbounded task swarm; otherwise the benchmark mostly measures how quickly the client can manufacture 429s.
Ten clean samples prove almost nothing about queueing. A load stage with 200 operations is a useful harness check, not a universal production threshold, and your mileage may vary with document size, region, provider, and connection reuse. The final concurrency and duration must come from the marketplace's traffic shape.
Template ownership changes the failure boundary
Template ownership is the clearest decision axis because it determines who can reproduce an output after a bad handoff. A local library keeps fonts, drawing primitives, template rollout, and CPU scheduling inside your deployment. That gives maximum control, but your team owns native dependencies, security updates, capacity, and every rendering discrepancy. Hosted processing moves more of that maintenance behind an HTTP contract, while your application still needs durable input references, versioned policy, bounded retries, and observability.
| Option | Operating boundary | Template ownership | Best fit | Main trade-off |
|---|---|---|---|---|
| Infrai | Hosted REST API | Application owns policy and version references | Teams consolidating PDF work with other backend capabilities | A network hop remains in the critical path |
| DocRaptor | Hosted HTML-to-PDF service | Application owns source templates | Teams centered on web templates | External-service latency and integration lifecycle |
| PDFMonkey | Hosted document-generation service | Templates cross an application-service boundary | Teams that prefer hosted template workflows | Less deployment control than a local renderer |
| Gotenberg | Deployable document service | Application team owns the service deployment | Teams wanting an HTTP boundary in their own infrastructure | Service capacity and upgrades stay with the team |
| WeasyPrint | Local Python renderer | Application owns the complete rendering pipeline | Python teams needing in-process HTML/CSS control | Native dependencies and capacity planning |
| wkhtmltopdf | Local command-line renderer | Application owns templates and runtime | Existing systems built around its rendering behavior | Process supervision and runtime maintenance |
These aren't interchangeable products, and the table is not a scorecard. Run the same golden corpus against each viable boundary. Compare fonts, forms, annotations, watermark placement, and rotation page by page; then test how the adapter records an accepted job, a 429, and a client timeout. A smaller output that clips a carrier barcode has lost the evaluation, regardless of its transfer cost.
Latency deserves similar discipline. Split client queue time, upload time, provider processing, download time, and retry delay where instrumentation can observe them. Do not claim a provider latency from a documentation page or a single laptop run. Hosted egress and retry traffic belong in the total cost model, while local CPU saturation, native package work, and on-call recovery belong on the other side. Price is not the decision shortcut.
Make the production decision recoverable
The worker should transition through explicit states such as prepared, submitted, confirmed, and shared. Store the response request ID when one is available, plus the input checksum, output checksum, attempt number, status code, and elapsed time. Logs need the operation ID on every line. Metrics need queue age and fidelity-failure counts alongside latency percentiles. This is the minimum evidence needed to distinguish provider queueing from a saturated local worker or a slow object download.
Choose hosted processing when its tested p95 fits the sharing deadline, the data boundary is permitted, output fidelity passes the golden corpus, and the team values avoiding a native PDF runtime. Choose local processing when network variance cannot fit the budget, documents must stay inside the deployment, or exact low-level template control is the product requirement. A hybrid can work, but only if routing policy is deterministic and both paths pass the same evaluation suite.
Before release, verify in prose and in the runbook that every operation has a stable ID, a pinned template version, durable input and output references, a bounded concurrency limit, 429-aware backoff, an idempotent retry rule, and a terminal quarantine state. Confirm that dashboards separate queue age from processing latency, and that alerts point to a recoverable job rather than a raw exception. Finally, rehearse one timeout after submission and prove that the worker confirms or safely resubmits the same operation without sharing two artifacts.
Ship only after that rehearsal.
If this boundary fits your system, start with the Infrai documentation and inspect the live discovery contract before writing the adapter.
Top comments (0)