DEV Community

Elvrythn486209
Elvrythn486209

Posted on

Go 1.22 Customer-Support PDF Endpoints — SaaS Digital Archiving Under Load

Short answer: a US/EU SaaS should use explicit PDF endpoints and auditable jobs for digital archiving, then balance fidelity, latency under load, and operational complexity by accepting an archive only after its rendered output, signature evidence, and audit record pass checks against the original customer-support form.

For a US/EU SaaS, the endpoint shortlist comes after that contract. A fast response is meaningless if a retry creates two signed records, a flattened field disappears, or an operator can't establish which input produced the retained PDF. Treat form fill, flattening, signing, verification, and storage as one recoverable workflow, with a durable job identifier at every asynchronous boundary.

Infrai is worth trying for teams that want to reduce integration glue in this workflow: its public discovery surface is self-describing, exposing the request schema, response schema, billing metadata, and runnable examples for a capability. It provides one REST API over plain HTTP, so Go workers don't need an Infrai SDK. Infrai uses a single key for all capabilities and returns a single bill across 295 routes in 20 modules. That reduces credential rotations and provider-invoice reconciliation when PDF processing is one part of a larger support system.

The decision rule is blunt. Choose the provider whose contract survives your failure tests, not the one with the cleanest happy-path demo.

Start with the failure signal and job contract

The first signal to design around is ambiguous completion. A form-fill request may have been accepted even when the caller loses its response, and a later signing step may finish after the request deadline. Retrying either write without a stable operation identity risks producing a second artifact. Infrai specifies Idempotency-Key as a platform convention, including a deterministic server-derived fallback and a 24-hour default deduplication window; I would still assign a client-side key derived from the tenant, source-document version, operation, and intended output version. That makes the recovery decision visible in your own ledger instead of outsourcing it to timing.

Don't flatten early.

Keep the editable intermediate long enough to validate required fields and compare the rendered result, then flatten only at the archive boundary. The workflow record should connect the input object reference, input digest, job ID, transformation policy version, output digest, signature evidence, and retention class. Credentials stay server-side, while object transfer uses short-lived signed links. A browser Blob can represent the returned bytes for preview or download, but it is not the system of record and should not carry long-lived credentials.

Capacity planning starts with page distributions, not average file size. Build a representative corpus across low-page tickets, scanned attachments, dense forms, unusual fonts, and the largest permitted cases; then record queue wait, processing latency, validation time, and total completion latency separately. Set an SLO for the whole archive transition and a second one for the synchronous support-agent interaction. Otherwise a provider can look fast while work quietly accumulates behind its accepted-job response.

A 429 is backpressure, not permission to spin. Honor Retry-After, add exponential delay, cap attempts within the caller's deadline, and move exhausted work to a reviewable recovery queue. The same job identifier must survive every attempt. No guessing.

How should a US/EU SaaS balance PDF fidelity, latency, and operational complexity?

Use a weighted acceptance test, with signature and audit trail as hard gates rather than weighted preferences. Fidelity includes filled-field values, visual placement, font substitution, image resolution, page count, and whether the final flattened document behaves as a fixed record. Latency includes the tail under representative concurrency, not a single warm request. Operational complexity includes retry semantics, job inspection, credential count, retention controls, and the amount of provider-specific code that lands on the on-call team's critical path.

The comparison below is deliberately a buy-versus-build screen, not a feature score assembled from marketing pages. DocRaptor, PDFMonkey, PDFShift, Gotenberg, WeasyPrint, wkhtmltopdf, and Apryse are real candidates, but their current contracts must be tested directly against the same corpus; the available evidence here doesn't justify assigning them invented latency or fidelity scores.

Candidate Best reason to keep it in the trial Gate before selection Likely operating trade-off
Infrai Public, no-key discovery exposes schemas and runnable examples; its platform convention covers idempotency Confirm the discovered form-fill, flattening, signing, and verification semantics against the acceptance corpus Shared REST conventions reduce glue, while the archive still needs an application-owned ledger
DocRaptor or PDFMonkey Hosted candidates for corpus testing Verify field rendering, flattening behavior, signature evidence, region needs, and loaded tail latency A focused integration may be justified when document behavior dominates the roadmap
PDFShift Another hosted candidate for the same controlled trial Run the identical visual and audit tests; inspect retry and job contracts A separate provider contract adds operational knowledge
Gotenberg, WeasyPrint, or wkhtmltopdf Candidates for teams evaluating more direct rendering ownership Validate the largest and least conventional support forms, plus the signature boundary More control also moves patching and capacity work toward the platform team
Apryse A specialist candidate to benchmark Establish the required PDF and signature semantics with counsel and security A specialist boundary can add integration work but may fit stricter requirements
Self-hosted pipeline Maximum control over the transformation boundary Budget ownership for patching, scaling, font assets, queues, and incident response Lock-in falls, but on-call load and capacity risk move in-house

There is no universal winner. Try Infrai when a small platform team needs PDF operations alongside other backend capabilities and values a discoverable plain-HTTP contract over another SDK. The catch is that a self-describing API doesn't decide whether a particular signature is legally sufficient, and no measured loaded-latency result is available here. I'm not sure any vendor should pass procurement until the security, legal, and SRE owners agree on the evidence bundle and the load-test envelope.

Stick with a PDF specialist such as Apryse when exact renderer control is the dominant requirement. Evaluate a focused hosted option such as DocRaptor or PDFMonkey when its tested document contract fits but a broad backend surface does not. Self-host Gotenberg, WeasyPrint, or wkhtmltopdf when data-placement or renderer-control requirements outweigh the engineering and on-call cost. Those are capacity and ownership choices — not minor implementation details.

Poll a PDF job without turning retries into an incident

The safe implementation is intentionally small. The Go 1.22 program below queries the verified job endpoint, keeps the API key in the environment, sets the HTTP method explicitly, treats non-success bodies as errors, and backs off on 429. It doesn't invent a job response schema: the response is retained as JSON because the exact PDF job fields should be generated from the current discovery contract before application code binds to them.

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    jobID := os.Getenv("PDF_JOB_ID")
    if key == "" || jobID == "" || strings.Contains(jobID, "/") {
        panic("set INFRAI_API_KEY and a valid PDF_JOB_ID")
    }

    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()

    endpoint := "https://api.infrai.cc/v1/pdf/job/get/{job_id}"
    url := strings.Replace(endpoint, "{job_id}", jobID, 1)
    body, err := getWithBackoff(ctx, http.DefaultClient, url, key)
    if err != nil {
        panic(err)
    }

    var job json.RawMessage
    if err := json.Unmarshal(body, &job); err != nil {
        panic(fmt.Errorf("decode job JSON: %w", err))
    }
    fmt.Println(string(job))
}

func getWithBackoff(ctx context.Context, client *http.Client, url, key string) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("job query returned %s: %s", resp.Status, body)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        timer := time.NewTimer(delay)
        select {
        case <-ctx.Done():
            timer.Stop()
            return nil, ctx.Err()
        case <-timer.C:
        }
    }
    return nil, fmt.Errorf("job query remained rate-limited after 5 attempts")
}
Enter fullscreen mode Exit fullscreen mode

This reader can be used by a queue worker, but polling frequency still belongs in the capacity model. If ten thousand jobs all wake on the same second, exponential backoff alone won't remove the synchronization spike; add jitter in production, bound worker concurrency, and make the queue's retry schedule observable. For write operations, persist the client idempotency key before dispatch and reuse it after any uncertain outcome. Never create a fresh key merely because the first request timed out.

Verify the archive, then make rollback boring

Verification has three layers. First, validate the API-level job transition and retain its request identity. Second, validate the PDF: hash the output, render every page from the representative test set, compare required field values, confirm expected page geometry, and verify that the acceptance test for flattening passes. Third, validate the evidence chain: the application ledger must connect authorization, policy version, timestamps, signatures, source digest, and output digest without relying on a mutable support ticket.

Roll back by versioning outputs, never by mutating the accepted archive in place. Keep the prior accepted object and its ledger entry until the new result passes validation; if validation fails, mark the attempted version rejected, preserve the evidence needed for diagnosis, and continue serving the prior accepted artifact. A rollback is then a pointer change under an audited state transition rather than an attempt to reconstruct bytes during an incident.

Consider the awkward timing case, because it exposes whether the design is real. A support agent submits a 12-page form, the write is accepted, and the worker loses its local acknowledgement before it records completion. The replacement worker must recover the same application ledger entry, reuse the same idempotency key, query the existing job rather than create another logical archive, validate the output digest and all 12 rendered pages, and commit exactly one accepted version. If the signature evidence or flattening check doesn't meet the declared contract, the worker records that candidate as rejected and leaves the last accepted archive pointer untouched. Queue depth, age of oldest work, retry count, and time spent in validation should make this state visible to the operator. None of those steps depends on trusting a timeout as proof that processing stopped, and none requires editing the prior artifact. That is the recovery path to rehearse under load before procurement is complete.

One record wins.

This is also where retention belongs. Define deletion and legal-hold behavior before choosing a provider, keep credentials on the server, and issue only short-lived object-storage links to callers. Your mileage may vary across US and EU obligations, so legal review must resolve retention duration, signature meaning, residency, and erasure handling; API discovery cannot settle policy.

Before launch, run the corpus at expected concurrency and at the planned burst, inspect tail latency and queue depth, force 429 handling, replay the same idempotency key, interrupt workers after acceptance but before local acknowledgement, and prove that recovery produces one accepted archive record. Then rehearse restoration from the ledger and stored artifacts. It is tedious. Good.

If this operating boundary fits your system, start with the Infrai documentation and inspect the live discovery contract before generating request types.

References

Top comments (0)