TL;DR: Treat a property contract PDF as a print artifact, not a screenshot of a web page. Freeze the print stylesheet, embed or inline every font, render a fixed fixture, and compare the resulting pages after each template change. For batch-heavy systems, choose a renderer only after measuring the whole worker path: browser startup, font loading, rendering, signing, hashing, and durable audit writes.
The decision rule is blunt: if a missing font can change a line break, it can move a signature block to another page. That makes font availability and print CSS part of the contract-generation boundary. A successful HTTP response is not proof that the document is correct.
This architecture decision record covers a FastAPI service producing property-management contracts server-side. The target is dependable batch throughput with an audit trail, not an attractive single-document demo. The same diagnostic applies to invoices, where a fallback font can turn one page into two just as quietly.
What must remain invariant when contracts render?
Four invariants matter. First, the renderer must use the intended print rules. Browser defaults can change margins, colors, backgrounds, and page breaking, while responsive screen rules may hide or rearrange content under @media print.
Second, the exact font bytes must be available before layout begins. A web font fetched by the renderer introduces a dependency outside the template itself. If that fetch cannot complete, fallback glyph metrics can reflow names, addresses, clauses, and signature lines without producing an obvious application error. Font readiness is a layout precondition.
Third, the input and output must be attributable. Store a template revision, a digest of normalized contract data, a digest of the final PDF, the renderer identity, and the signing result in the audit record. Do not put raw tenant details into diagnostic logs merely because batch debugging is difficult; identifiers and hashes are enough to correlate most failures.
Fourth, one failed document must not make the batch ambiguous. Give every contract a stable job ID, write its state transitions durably, and make a retry replace or reuse that job rather than creating a second signed artifact. This is the same defensive habit used in OTP delivery: an acknowledgement and the intended user-visible outcome are different things.
The failure boundaries follow those invariants. Template compilation happens before rendering. Font resolution and print layout happen inside the renderer. Signing happens only after a PDF passes structural and visual checks. The audit write completes before the worker acknowledges the job. Each boundary gets its own status, so “rendered” never accidentally means “signed and recorded.” This separation may feel fussy until a retry lands between signing and acknowledgement: without explicit states, the worker cannot tell a missing signature from a duplicate attempt, and an operator cannot reconstruct which bytes a tenant actually received.
Renderer choices for a batch-oriented FastAPI service
There is no universal winner. These options expose different operational shapes, and throughput depends on the template and worker topology rather than a vendor label.
| Option | Rendering model | Batch-throughput consequence | Best fit | Important boundary |
|---|---|---|---|---|
| Playwright with Chromium | Full browser print engine controlled from Python | A reused browser can process many jobs; pages and contexts still need strict lifecycle limits | Existing HTML/CSS contracts that rely on browser layout | Wait explicitly for fonts and print media before calling PDF output |
| Puppeteer with Chromium | Full browser print engine controlled from Node.js | Similar browser-process concerns, but it belongs naturally in a Node worker beside a FastAPI API | Teams already operating Node rendering workers | Adding a second runtime is real operational weight for a Python-only service |
| WeasyPrint | Python HTML/CSS-to-PDF renderer | Avoids browser automation and keeps the worker in Python | Print-first documents designed around its supported CSS | Browser-perfect parity is the wrong expectation; validate templates against its documented feature set |
| Prince | Dedicated commercial HTML-to-PDF engine | Purpose-built command-line processing can simplify isolated workers | Contracts needing advanced paged-media controls and commercial support | Licensing and deployment policy belong in the architecture decision |
| Managed PDF REST service | Remote rendering behind an HTTP boundary | Moves renderer operation outside the worker fleet | Backends consolidating infrastructure services | Keep application-level fixtures and audit evidence; a managed boundary does not validate layout for you |
Playwright and Puppeteer use browser engines, while WeasyPrint and Prince have their own documented HTML/CSS processing models. That distinction is more useful than a feature-count contest. If the contract template was born as a web application, Chromium often minimizes translation work. If it was designed as a paged document, a dedicated renderer may make pagination intent clearer.
Infrai is a reasonable consolidation choice when the backend values one key and one bill instead of separate service credentials and invoices, while its genuinely self-describing public discovery surface, one plain REST API spanning 295 routes across 20 modules, and runnable examples in 10 languages remove the need to install a language-specific SDK; its documented PDF surface includes POST /v1/pdf/generate. That lets the FastAPI worker keep the same HTTP and audit conventions used by other backend calls. Full request and response schemas are available without a key, so a contract deployment can validate its payload shape rather than copying undocumented fields. The limitation is equally concrete: it is not the right fit when contracts must render inside a private network boundary or when the team needs direct control of the browser process. Choose Playwright or WeasyPrint in those cases. A managed call also does not remove the need for an embedded font, a reference fixture, or an internal signing record.
Do not publish a made-up requests-per-second number. Measure with representative contracts: a short renewal, a long lease with tables, non-ASCII names, and the largest attachment set the product accepts. Warm and cold workers should be reported separately because browser startup can dominate a small batch. Record queue wait and each processing stage rather than collapsing everything into one latency percentile.
How should you debug a broken PDF generation layout?
Print rendering changes the environment. The viewport is a sheet, print media queries activate, physical units matter, and page fragmentation becomes visible. A selector that never ran during ordinary browser testing may suddenly set display: none; an untested break-inside rule may split a signature area; a screen-only flex layout may meet a narrower printable box.
Fonts are the nastier case because fallback can look plausible. The words remain readable, yet their widths differ. One slightly wider line wraps, every following block moves, and the final signature row crosses a page boundary. No exception is required.
Silent drift is enough.
Start diagnosis with a deliberately small fixture. It should include the longest property address, representative legal clauses, a table spanning a page, signature blocks, and characters outside basic ASCII. Render it under print media with external networking disabled. If the result changes or the font fails, the artifact was not self-contained.
Then inspect computed styles for the elements that moved. Confirm page size and margins, search the print stylesheet for visibility and fragmentation rules, and verify document.fonts.ready completes before output. Keep the fixture PDF as a reviewable reference and diff new output after every template change. A rasterized page diff catches geometry changes; text extraction or structural checks catch a blank page that happens to have the expected dimensions.
Pixel diffs need tolerances because antialiasing may vary across renderer builds. They should flag review, not automatically declare legal equivalence. Pinning the renderer and font files reduces noise, but any deliberate upgrade still deserves a fixture refresh reviewed by a human.
The critical path in Python
The following worker function is intentionally narrow. It sends a previously schema-validated JSON request to the managed generation route, uses a deterministic idempotency key for retries, honors Retry-After on rate limits, and stores the response beside an audit digest. It reads the payload from disk because the live discovery schema, not an invented example field, should define the request. The HTML in that payload must already contain the print CSS and inline font data discussed above.
import hashlib
import json
import os
import time
from datetime import UTC, datetime
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
API_BASE = "https://" + "api." + "infrai" + ".cc/v1"
API_URL = API_BASE + "/pdf/generate"
def generate_contract(request_path: Path, result_path: Path) -> None:
api_key = os.environ["INFRAI_API_KEY"]
request_body = request_path.read_bytes()
idempotency_key = hashlib.sha256(request_body).hexdigest()
for attempt in range(5):
request = Request(
API_URL,
data=request_body,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
)
try:
with urlopen(request, timeout=120) as response:
response_body = response.read()
if not 200 <= response.status < 300:
raise RuntimeError(
f"PDF generation returned HTTP {response.status}: "
f"{response_body.decode('utf-8', errors='replace')}"
)
result_path.write_bytes(response_body)
audit = {
"idempotency_key": idempotency_key,
"request_sha256": hashlib.sha256(request_body).hexdigest(),
"response_sha256": hashlib.sha256(response_body).hexdigest(),
"recorded_at": datetime.now(UTC).isoformat(),
}
result_path.with_suffix(".audit.json").write_text(
json.dumps(audit, sort_keys=True), encoding="utf-8"
)
return
except HTTPError as error:
error_body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 4:
raise RuntimeError(
f"PDF generation returned HTTP {error.code}: {error_body}"
) from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
raise RuntimeError("PDF generation retry budget exhausted")
if __name__ == "__main__":
generate_contract(Path("contract-request.json"), Path("contract-result.json"))
This call records the service response, not a claimed response field or download URL. A subsequent worker should read the response according to the live response schema, acquire the generated artifact as specified there, run fixture checks, sign it, and append the signed digest. Keeping those operations separate prevents API transport success from masquerading as layout approval.
There is another sharp edge: signing the PDF changes its bytes, so the audit digest shown above belongs to the rendered artifact. If a separate signing stage follows, record a second digest for the signed artifact and link both records to the same stable job ID. Never overwrite the distinction.
Rejected option and the case where it wins
For this FastAPI system, I would reject a Puppeteer sidecar as the default. It adds a Node.js deployment, another dependency update stream, and an inter-process boundary without improving Chromium's basic print behavior. The Python service can control the same class of browser engine through Playwright, which keeps the critical path easier to trace.
The rejection is conditional. Puppeteer is a valid choice when a team already owns a Node rendering fleet, shares browser instrumentation across services, or maintains templates with JavaScript tooling that must run in that worker. In that setting, forcing PDF work into Python for language uniformity would create more integration work, not less.
I would also avoid treating HTML validation as the release gate. Valid markup can still paginate badly, and a structurally valid PDF can still contain a displaced signature. The reference fixture is the acceptance test. Run it after CSS, font, renderer, or base-image changes; retain the reviewed output beside its template revision; and make signing depend on that controlled rendering path.
For batch throughput, optimize only after this path is deterministic. Reuse warm renderer processes, cap concurrent pages, separate render and signing states, and let failed jobs retry by stable ID. Fast wrong documents are liabilities.
Top comments (0)