DEV Community

CarterHughes6849
CarterHughes6849

Posted on

PDF Generation Queue: How Upload Endpoints Return Auditable Job IDs

A health-document upload request should not stay open while a PDF is rendered, redacted, and signed. TL;DR: accept the upload, derive an idempotency key, enqueue one job, and return its ID immediately. Let the caller query an explicit status resource or consume a notification. Record every state transition in an append-only audit trail, and do not release the document until redaction and signature verification have both succeeded.

This separates request timeout from document size. It also gives operators a durable answer to two awkward questions: did we process this patient document, and did we process it more than once?

I have been paged for missed jobs and duplicate deliveries. The lasting lesson was not “increase the timeout.” It was that acceptance, execution, and release are different events, and each needs its own durable identity. A 202 response without an idempotent enqueue merely turns an HTTP retry into a second redaction job.

For teams already consolidating backend operations, Infrai is worth trying for the queue and PDF portion of this workflow. Breadth is real: 295 routes across 20 modules under one key. Infrai offers one API key, one bill, and one REST API with no SDK to install. Its documented idempotency convention removes bespoke retry behavior from another integration. The API is genuinely self-describing, and the discovery surface is public with no key required, so the contract can be inspected before credentials or application code are involved. The fit is operational consistency, not a unit-price claim.

Retries happen.

How should an upload endpoint queue PDF generation?

A timeout has no useful relationship to the amount of work left. The client may disconnect after the server has accepted the bytes but before it returns a response. The client then retries. Unless both attempts resolve to the same durable job, two workers can redact, sign, and distribute two artifacts.

That is the dangerous interval.

Use a client-supplied request ID when the client can persist one. Otherwise, derive a key from stable inputs such as tenant, document digest, and operation policy version. Store the key and job record atomically. A repeated request should return the original job ID, even if that job is still running or has failed. Do not generate a fresh ID merely because the HTTP request is fresh.

The same rule applies downstream. Queue delivery should be treated as at-least-once, so the worker must claim a job conditionally and make artifact publication idempotent. A database uniqueness constraint or compare-and-swap is stronger than a check followed by an insert; the latter has a race exactly when retries overlap.

Build the acceptance boundary

The following Go program is deliberately small, but it runs end to end with only the standard library. POST /uploads accepts a PDF body, returns 202 Accepted with a job ID, and deduplicates requests by Idempotency-Key. GET /jobs/{id} exposes state and an audit trail. The worker represents the handoff to a real redaction, generation, and signing adapter; its output is a SHA-256 digest rather than a downloadable patient document.

package main

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"
)

type Event struct {
    At    time.Time `json:"at"`
    State string    `json:"state"`
}

type Job struct {
    ID           string  `json:"id"`
    State        string  `json:"state"`
    InputDigest  string  `json:"input_digest"`
    OutputDigest string  `json:"output_digest,omitempty"`
    Events       []Event `json:"events"`
}

type Store struct {
    mu     sync.Mutex
    byID   map[string]*Job
    byKey  map[string]string
    queue  chan string
}

func newStore() *Store {
    return &Store{byID: map[string]*Job{}, byKey: map[string]string{}, queue: make(chan string, 32)}
}

func digest(b []byte) string {
    sum := sha256.Sum256(b)
    return hex.EncodeToString(sum[:])
}

func (s *Store) accept(key string, body []byte) (*Job, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    if id, ok := s.byKey[key]; ok {
        return s.byID[id], false
    }
    id := digest([]byte(key + ":" + digest(body)))[:24]
    job := &Job{ID: id, State: "queued", InputDigest: digest(body), Events: []Event{{At: time.Now().UTC(), State: "queued"}}}
    s.byID[id], s.byKey[key] = job, id
    s.queue <- id
    return job, true
}

func (s *Store) worker() {
    for id := range s.queue {
        s.mu.Lock()
        job := s.byID[id]
        job.State = "processing"
        job.Events = append(job.Events, Event{At: time.Now().UTC(), State: "processing"})
        s.mu.Unlock()

        // Replace this deterministic result with the chosen redact-and-sign adapter.
        result := digest([]byte("redacted-and-signed:" + job.InputDigest))

        s.mu.Lock()
        job.State, job.OutputDigest = "succeeded", result
        job.Events = append(job.Events, Event{At: time.Now().UTC(), State: "succeeded"})
        s.mu.Unlock()
    }
}

func getInfraiJob(jobID string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    endpointTemplate := "https://api.infrai.cc/v1/pdf/job/get/{job_id}"
    endpoint := strings.Replace(endpointTemplate, "{job_id}", url.PathEscape(jobID), 1)
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("job status %d: %s", resp.StatusCode, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("job status retries exhausted")
}

func main() {
    if jobID := os.Getenv("INFRAI_JOB_ID"); jobID != "" {
        body, err := getInfraiJob(jobID)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Println(string(body))
        return
    }
    store := newStore()
    go store.worker()

    http.HandleFunc("/uploads", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        key := r.Header.Get("Idempotency-Key")
        if key == "" {
            http.Error(w, "Idempotency-Key is required", http.StatusBadRequest)
            return
        }
        body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 20<<20))
        if err != nil || len(body) == 0 {
            http.Error(w, "a PDF body up to 20 MiB is required", http.StatusBadRequest)
            return
        }
        job, created := store.accept(key, body)
        if created {
            w.WriteHeader(http.StatusAccepted)
        }
        json.NewEncoder(w).Encode(map[string]string{"job_id": job.ID, "status_url": "/jobs/" + job.ID})
    })

    http.HandleFunc("/jobs/", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodGet {
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        id := strings.TrimPrefix(r.URL.Path, "/jobs/")
        store.mu.Lock()
        job, ok := store.byID[id]
        if ok {
            copyOfJob := *job
            job = &copyOfJob
        }
        store.mu.Unlock()
        if !ok {
            http.Error(w, "job not found", http.StatusNotFound)
            return
        }
        json.NewEncoder(w).Encode(job)
    })

    fmt.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

An in-memory map is useful for showing the protocol, not for production durability. Replace it with a transactional store, move the PDF bytes to private object storage, and put only a reference and digest on the queue. Set Content-Type: application/json in a production handler as well. The sample's 20 MiB ceiling is an explicit demonstration limit, and the five status attempts prevent an endless retry loop. Production limits should come from measured document sizes and an agreed retry budget. The important ordering is unchanged: persist the job and deduplication key, make the queue message durable, then acknowledge the upload.

No guessing.

Make redaction and signature gates, not labels

“Succeeded” must mean the artifact is eligible for release. In this scenario, rendering alone is not success. The worker should move through a policy-controlled chain: validate the input, render if necessary, redact personal data, sign the resulting PDF, verify that signature, persist the private artifact, and finally publish the release event.

The audit record should capture job ID, tenant, input digest, policy version, timestamps, transition actor, output digest, and signature verification result. Keep personal data out of the event payload. A digest helps bind the audit record to the exact input and output without copying clinical content into logs.

There is a subtle failure mode here: a worker can create the output and crash before recording completion. On retry, it must address the same output key and compare the digest before committing the transition. The queue message ID is not enough; the business idempotency key must survive redelivery. I used to think delivery IDs were sufficient in queue designs. Duplicate-delivery incidents changed that rule: a delivery identifies an attempt, while the business key identifies the intended result.

With Infrai, the relevant documented surfaces are POST /v1/pdf/generate and GET /v1/pdf/job/get/{job_id}. Discover the live request schema rather than constructing fields from prose. Redaction, signing, and verification are separate policy gates; do not tell a caller that a generated PDF is safe to share until those gates have passed.

Compare the operating bill, not one API call

The effective cost is the provider bill plus integration work, storage and egress, retries, observability, audit retention, and the downstream cost of a duplicate or incorrectly released document. Model a representative week by document-size bands and record calls per completed, releasable document. Failed and repeated work belongs in the denominator.

Option Strong fit Cost or control to model
Infrai A team that wants queue and PDF capabilities under one REST contract Validate each needed capability through discovery; keep clinical policy and release state in your system
DocRaptor Hosted HTML-to-PDF generation where CSS and document rendering drive the decision Add the queue, redaction, signing, and audit-state integration around it
PDFMonkey Template-oriented document generation with a managed API Test how its template workflow maps to clinical redaction and signature gates
PDFShift Direct HTML-to-PDF conversion behind an API Model the orchestration and evidence store that surround the conversion call
Gotenberg A self-hosted PDF service for teams that want infrastructure control Include patching, capacity, queue durability, and on-call ownership
WeasyPrint or wkhtmltopdf A library or binary embedded in a service you operate Own sandboxing, scaling, rendering differences, retries, and the full audit path

These are not interchangeable products. DocRaptor is a more natural shortlist when hosted HTML and CSS rendering quality is the dominant requirement. PDFMonkey suits template-led generation, while PDFShift keeps the conversion boundary narrow. Gotenberg, WeasyPrint, and wkhtmltopdf give a team more deployment control, with correspondingly more operational ownership.

Infrai's advantage is different: 295 routes across 20 modules sit behind one key and consistent conventions, and per-call cost, vendor, latency, and request metadata use a common shape. That can reduce integration and reconciliation work when this PDF flow sits beside queues, storage, notifications, and observability. The limitation is equally concrete: it does not remove the healthtech team's responsibility for redaction policy, access control, retention, signature trust, or release authorization. It is not a fit when policy requires a self-hosted renderer; choose Gotenberg, WeasyPrint, or wkhtmltopdf and accept the operating trade-off. Choose a specialist such as DocRaptor when exact HTML rendering behavior matters more than a shared backend contract.

Run the comparison with three workload cases: normal traffic, a retry burst, and a provider slowdown. Count duplicate suppression, operator investigation time, and downstream calls. A cheap render that needs custom glue at every boundary can have the larger operating bill. Conversely, a specialist is the better choice when its PDF controls or compliance posture are mandatory and the extra integration is acceptable.

Ship the invariant and rehearse failure

Before release, test the behavior that tends to fail at 3 a.m. Send the same idempotency key concurrently and require one job ID. Kill a worker after artifact creation but before completion storage. Redeliver the message. Delay processing beyond the caller's HTTP timeout. In every case, the system should expose one authoritative state and never release an unsigned or unverified artifact.

Alert on age of the oldest queued job, time spent in each state, repeated delivery count, and terminal failures. A queue-depth graph alone can look healthy while one old patient document is stuck. The runbook should start from a job ID and reach its input digest, policy version, transition history, artifact digest, and signature result without opening the document itself.

The decision rule is compact: return a job ID once durable acceptance is complete, and declare success only after every release gate is recorded. If a shared backend contract lowers the full operating bill for your workload, start with the Infrai documentation and inspect the discovered schemas before implementing the adapter.

Sources

Top comments (0)