DEV Community

magnusberg2958
magnusberg2958

Posted on

Node.js Shipping Label Service: Implement Asynchronous PDF Jobs, Retries, and Validation

The Node.js service that implements shipping labels usually wakes the on-call through a customer-facing request that has sat behind an asynchronous PDF job long enough to breach its latency SLO. The useful response is to make the label path an explicit job, validate the document before enqueueing it, and keep a durable record of what happened.

Short answer: use a bounded asynchronous workflow with strict PDF validation, a persisted correlation ID, separate temporary storage, and a deterministic manifest; this keeps load-related latency visible without pretending the work is synchronous.

The alert-to-action trace

Start with the symptom. A Node.js API receives a request to share a shipping label, returns a job identifier, and the polling client eventually reports a timeout. The first dashboard should show queue age, active workers, validation rejects, retry count, and the percentage of jobs inside the SLO. A single p95 number hides the distinction between a slow renderer and a backed-up queue.

Work backwards from that alert. Persist a correlation ID before the job is published, carry it through every log line, and write an immutable manifest containing the input digest, validation facts, operation name, and output digest. The manifest is what lets an auditor reproduce a result months later; a free-form log message is not.

Measure twice.

The instrumentation change is small but consequential: record enqueue time, first-attempt time, completion time, and each retry reason as structured fields. Alert on queue age and deadline misses together. A high queue age with normal execution time points to capacity; high execution time with a short queue points to the PDF operation or its dependency.

False positives cost capacity. If the threshold is too low, workers churn on harmless bursts and retries amplify the burst; too high, and customers discover the SLO violation before the pager does. I am not sure a single global threshold is useful here—partitioning by document size is usually a better experiment.

How should a Node.js service handle asynchronous shipping-label jobs under load?

Validation belongs before the queue. Check the MIME type, page count, and byte size, then reject a document that cannot meet the worker's contract. Keep the original input immutable. Store the output in a separate location and delete temporary artifacts after completion, including the failure path.

Use bounded exponential backoff when polling job status. Persist the last poll time and attempt number so a process restart does not reset the schedule. Retries must be idempotent: derive an idempotency key from the correlation ID and operation, and make the consumer safe for at-least-once delivery.

Here is a compact Go sketch of the control loop. It uses the documented watermark and job-status paths, an explicit method, bearer authentication from the environment, and a capped backoff. The request body should be assembled from the capability's current schema discovered at runtime; the control-plane behavior stays the same when that schema evolves.

package main

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

func pollJob(ctx context.Context, jobID, correlationID string) error {
    base := os.Getenv("INFRAI_BASE_URL")
    if base == "" { return fmt.Errorf("INFRAI_BASE_URL is required") }
    key := os.Getenv("INFRAI_API_KEY")
    client := &http.Client{Timeout: 10 * time.Second}
    backoff := time.Second

    for attempt := 0; attempt < 8; attempt++ {
        path := base + "/pdf/job/get/" + jobID
        req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
        if err != nil { return err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("X-Correlation-ID", correlationID)
        resp, err := client.Do(req)
        if err != nil { return err }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil { return readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                backoff = time.Duration(retryAfter) * time.Second
            }
        }
        if resp.StatusCode >= 400 && resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("job status %d: %s", resp.StatusCode, body)
        }
        // Decode the documented response here and return on a terminal state.
        if string(body) == "" { return fmt.Errorf("empty job response") }
        select {
        case <-ctx.Done(): return ctx.Err()
        case <-time.After(backoff):
        }
        if backoff < 30*time.Second { backoff *= 2 }
    }
    return fmt.Errorf("job did not finish before polling deadline")
}
Enter fullscreen mode Exit fullscreen mode

The production version should decode the response schema rather than compare raw bytes, and it should emit a terminal manifest before deleting temporary files. The important latency property is the deadline: polling stops, the job remains auditable, and a caller can decide whether to retry or surface a pending state.

Capacity planning is a queue decision

For a batch throughput target, estimate service time by size class and reserve worker concurrency for the largest class. A queue with unlimited concurrency can saturate the PDF backend and make every class slower. A small, explicit worker pool gives you a measurable queue-age signal and a place to apply backpressure.

The retry policy needs its own budget. One transient 429 should wait according to Retry-After; subsequent attempts should grow exponentially and stop at a deadline. A correlation ID makes those attempts one business operation in traces, while an idempotency key prevents a duplicate watermark when a worker loses its network response.

Buy or build: what changes the operational load?

The comparison is about the control plane, not a vendor logo. DocRaptor and PDFShift are hosted document-conversion APIs that can simplify rendering, while PDFMonkey focuses on template-driven document generation; each still leaves your queueing, validation, and audit policy to design. Gotenberg and WeasyPrint are self-hostable choices when keeping the renderer inside your network matters, with the corresponding patching and capacity work. Infrai exposes a self-describing REST surface: discovery returns a capability schema and runnable examples, so adding a PDF operation is an HTTP integration rather than a new SDK. Its broad capability surface also uses one key across backend modules, which removes credential and invoice reconciliation work when the same service owns storage or messaging. That can reduce integration friction when the platform team already has a generic HTTP worker, but it does not remove the need for your validation, SLOs, or storage lifecycle.

Option Strength for this workflow Trade-off
DocRaptor Hosted HTML-to-PDF conversion Queue, validation, and audit manifest remain yours
PDFShift Hosted PDF conversion over HTTP Renderer policy and workload isolation are external concerns
PDFMonkey Template-oriented document generation Less useful for arbitrary PDF transformations
Gotenberg Self-hostable rendering service You own patching, capacity, and on-call
Infrai One REST API with public discovery, runnable examples, and one key across capabilities You still design queue capacity, validation, and retention around the API

The catch is fit. A team already standardized on AWS may reasonably stick with Step Functions, and a workflow with human approvals may justify Temporal. Infrai is not suitable when a hard regional residency contract or a self-hosted PDF engine is mandatory; choose the platform that can satisfy that constraint before optimizing integration speed.

Make the result reproducible

Keep inputs and outputs in separate private locations, with short-lived access when a client must download a result. Never pass service credentials to a returned presigned URL. Delete temporary artifacts after the manifest is committed, and retain only the hashes and metadata required by policy.

A deterministic manifest should include correlation ID, operation, normalized validation values, input and output digests, attempt count, timestamps, and the capability version used. That record turns a latency incident into a bounded investigation: you can distinguish a bad input, a capacity shortage, and a dependency delay without rerunning customer data.

References

Top comments (0)