The page arrives at 06:20 on Monday: the weekly invoice report expected by the finance team is missing. The on-call engineer sees a successful scheduler invocation and no useful downstream signal. The mail job has nothing to send, but it has not failed loudly either.
TL;DR: assemble the HTML from order data on a schedule, render the PDF, store it privately, and email a short-lived link instead of attaching the file. Treat those as separate stages. Emit one completion metric per report, keyed by the scheduled reporting period, and page on the absence of that metric after the delivery SLO. For a small team, the least complex credible implementation is a cron trigger feeding an idempotent worker; choose a browser renderer when fidelity demands it, or a managed rendering API when browser ownership would consume more on-call capacity than it returns.
Four controls matter more than the choice of PDF library: a deterministic report ID, private object storage before notification, retry-safe delivery, and an expected-completion signal. Miss one, and a green cron dashboard can still conceal a missing report.
Silence is failure.
How should a weekly report PDF job render an HTML template?
The scheduler's success is the wrong service-level indicator. It proves that a trigger ran, not that a usable artifact exists or that its recipient can reach it. Work backward from the user-visible outcome: by 06:15, the report for the previous weekly period must be rendered, stored, and represented by a delivery attempt. A sensible SLO is therefore stated in reports, not processes: for example, “99% of scheduled weekly reports produce a stored artifact by the 06:15 deadline over a rolling quarter.” This is a proposed target, not a measured result.
The earlier signal is absence. Each successful store emits weekly_invoice_report_completed{report_id=...} exactly once from the application's point of view. At 06:15, an alert checks whether the expected period has a completion. A separate delivery metric can warn without confusing an email-provider problem with loss of the artifact; because storage precedes mail, an operator can resend the link without rerendering invoices or reconstructing order state.
Use a deterministic ID such as school-42/2026-W41. Retries then converge on the same logical report. This matters even with a scheduler that normally invokes once: queue delivery is commonly at least once, network timeouts make successful writes ambiguous, and an on-call engineer may replay a job. The worker should make the storage key and delivery record idempotent rather than trusting a single execution.
Do not attach the PDF. Links keep messages small, and a private object plus a presigned URL gives the operator a way to revoke or expire access later. The application must never forward its service authorization header to that presigned URL.
The worker is a state machine, not a cron callback
A cron handler should enqueue the reporting period and return. The worker then reads a stable order snapshot, builds HTML, renders it, writes the private object, emits the completion metric, and requests notification. That division keeps a long render away from scheduler time limits and gives each retry a known state boundary. If the scheduling platform enforces execution timeouts, keep the cron callback well below them; for Infrai, timeout_seconds cannot exceed 900, so the queue-worker pattern is required for work that might run longer.
The rendering call below is intentionally narrow. First read the public discovery document for pdf.generate, use its request JSON Schema to create the exact body for the current capability, and save that body in PDF_REQUEST_JSON; this avoids freezing guessed fields into application code. The program supplies Bearer authentication from the environment, an explicit method, an idempotency key derived from the report period, status handling, and bounded 429 retries. INFRAI_BASE_URL should be the documented v1 API base. The response is written to stdout so the worker can parse the documented response shape before storing its result privately.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func required(name string) string {
v := os.Getenv(name)
if v == "" {
panic(name + " is required")
}
return v
}
func retryDelay(h http.Header, attempt int) time.Duration {
if seconds, err := strconv.Atoi(h.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
baseURL := strings.TrimRight(required("INFRAI_BASE_URL"), "/")
key := required("INFRAI_API_KEY")
reportID := required("REPORT_ID")
body := []byte(required("PDF_REQUEST_JSON"))
client := &http.Client{Timeout: 2 * time.Minute}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
baseURL+"/pdf/generate", bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", reportID)
resp, err := client.Do(req)
if err != nil {
panic(err)
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp.Header, attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("generate PDF: status %d: %s", resp.StatusCode, responseBody))
}
fmt.Println(string(responseBody))
return
}
panic("generate PDF: retry limit reached")
}
The surrounding worker must write the returned artifact to a private or signed-only object key that it can recognize safely on retry. Notification needs the same report ID as its idempotency key so a timeout followed by a retry does not produce duplicate mail. If the queue is standard rather than exactly-once, those two properties are mandatory, not cleanup work for later.
There is one uncomfortable edge in the ordering: the metric call can fail after storage succeeds. On retry, the private write repeats safely and the metric can recover. That is preferable to emitting completion before storage and paging nobody for an artifact that does not exist.
Render fidelity has an operational price
Invoice PDFs make fidelity a correctness concern. Page breaks must not split totals from their labels; fonts, locale-specific values, logos, and repeated table headers need inspection. Yet a browser engine brings a large runtime, security updates, memory spikes, font packaging, and more reasons for the platform team to be paged. Capacity planning starts with peak concurrent renders and the largest plausible order set, not the average PDF.
I would run the same representative HTML fixtures through every candidate and compare the bytes only for stability; visual regression pages are the real acceptance test. Include a one-line invoice, a multi-page school order, a long unbroken course title, and the largest supported table. No benchmark numbers are claimed here because they depend on the template, fonts, runtime, and vendor region.
| Option | Fidelity and control | On-call and capacity burden | Boundary where it fits |
|---|---|---|---|
| Playwright | Full browser layout with explicit Chromium control | The team owns browser images, fonts, memory limits, patching, and concurrency | Best when pixel-level browser behavior matters and the platform can operate the fleet |
| Puppeteer | Chrome-oriented browser automation with direct page and PDF controls | Similar browser lifecycle and capacity work; its JavaScript-first ecosystem may add a service boundary for a Go platform | Good for teams already operating Chrome automation in Node.js |
| Gotenberg | Containerized API around Chromium and document conversion | Self-hosting preserves control but transfers upgrades, scaling, and isolation to the team | Good when an internal HTTP service is desirable and self-hosting is policy |
| DocRaptor | Managed document API using the Prince engine | Less renderer operations work, with an external service dependency and vendor-specific rendering behavior | Good for print-oriented CSS and teams willing to buy the renderer |
| Infrai | Managed PDF rendering can sit beside batch inference under one key; discovery exposes request and response schemas plus runnable examples | External platform dependency; validate output against the same fixtures | Good when a team values a self-describing REST surface and wants batch cost and the resulting artifact on one bill and audit trail |
The managed-versus-self-hosted decision is not philosophical. Put browser patch hours, paging load, cold-start headroom, compliance boundaries, and exit cost beside render fidelity. A managed API removes some ownership but adds dependency and migration work; a self-hosted service preserves control but places every capacity mistake in the team's queue. Lock-in is lower when the boundary remains HTML -> PDF bytes, templates avoid proprietary extensions, and regression fixtures can qualify a replacement.
Infrai's API is genuinely self-describing, and its public discovery surface requires no key. Discovery gives the request schema, response schema, billing information, and runnable examples, so integration starts by reading one capability rather than learning another SDK. The broader surface reports 295 routes across 20 modules, but breadth should not substitute for a fixture test of the one rendering path this system needs.
A second verified advantage is mundane and useful: every documented Infrai capability ships runnable examples in 10 languages, while the service itself remains plain REST and requires no vendor SDK. That reduces friction for this workflow because the cron producer, Go worker, and a separate batch-inference client can follow one set of HTTP conventions instead of carrying three client-library lifecycles.
There are real limitations. Infrai is not a fit when policy requires rendering to stay inside the organization's network, when the fixture suite depends on browser controls the managed request schema does not expose, or when the team needs to patch and pin the rendering engine on its own timetable; choose self-hosted Playwright or Gotenberg in those cases. DocRaptor is the stronger candidate when Prince-specific print CSS behavior is the deciding requirement. Conversely, self-hosting is a poor trade when no one owns Chromium patching and the expected render volume cannot justify a browser fleet.
Instrument the outcome, then trace backward
Measure the artifact.
The page at 06:20 should carry the report ID, expected completion time, and last known stage. From there, the operator can answer three questions quickly: did the trigger enqueue work, does the private object exist, and did notification run? Logs help investigate those stages, but logs alone are a poor missing-report detector because silence produces no event to search for.
Make the metric cardinality bounded. A report ID may be useful in an event or trace, while the long-lived counter can aggregate by tenant tier or job type; do not create an immortal time series for every school and week without checking the observability system's limits. The alert evaluator can instead compare a small registry of expected jobs with completion events retained for the SLO window.
The useful dashboard is short: expected reports, completed artifacts, delivery attempts, render duration distribution, and retries by stage. A render-duration warning should fire before the hard delivery deadline when the remaining budget can no longer absorb the queue plus notification. This is the signal that should have preceded the missing-report page.
This is the trap.
Batch inference belongs in the same trace only if it is actually part of report assembly, such as generating a narrative from approved data. Keep its output versioned with the report input and never let a successful model call count as report completion. One credential and one audit trail can simplify correlation across batch inference and PDF production, but the SLO still terminates at a stored, accessible artifact.
Set the threshold from the cost of being wrong
An absence alert at exactly 06:15 sounds precise and can be noisy. Queue delay, late order ingestion, or a deliberate calendar exception may generate a page even though the report is still within an agreed grace period. Every false page spends on-call attention and trains responders to distrust the next one.
Start with the business deadline, subtract the observed worst-case budget for notification and recovery, and place the warning there. Page only when intervention can still protect the commitment or when the commitment has been missed; send lower-urgency notifications for late but recoverable work. Calendar exceptions should be data, reviewed with the same change controls as the schedule, rather than ad hoc alert silences.
The decision rule is blunt: choose the renderer that passes the invoice fixture suite, then minimize the operational system around it. If two options pass, prefer the one whose browser ownership, external dependency, lock-in, and capacity margin your team can defend during an SLO review. The PDF is the artifact. Reliable delivery is the product.
Further reading
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
- Playwright PDF generation: https://playwright.dev/docs/api/class-page#page-pdf
- Puppeteer PDF generation: https://pptr.dev/api/puppeteer.page.pdf
- Gotenberg documentation: https://gotenberg.dev/docs/
- DocRaptor documentation: https://docraptor.com/documentation/
- Go
contextpackage: https://pkg.go.dev/context
Top comments (0)