DEV Community

GriffinHayes3461
GriffinHayes3461

Posted on

Node.js Service Report Generation — 5 Asynchronous Job, Retry, Validation, Latency Rules

Short answer: make report generation an explicit PDF job with strict input validation, a correlation ID, bounded exponential polling, and a deterministic manifest; keep form templates under an owner-controlled store and treat temporary files as disposable.

That decision is about ownership before it is about vendors. In a fintech system, the template is a regulated input: someone must be able to say which revision produced a customer statement, who approved it, and which exact bytes were used. Latency under load matters, but an accidentally mixed template and data set is the more expensive failure. I have seen teams spend a week tuning worker concurrency only to discover that their audit trail could not identify the template revision.

Ownership first.

What should a Node.js service validate before an asynchronous PDF job?

The request path should do cheap, local checks before it spends queue capacity. Check the MIME type, byte size, and page count of the uploaded template; reject a file that merely has a .pdf suffix. Persist a correlation ID alongside a content hash and template revision. The hash is useful when an auditor asks whether two reports really used the same input.

I keep inputs and outputs in separate namespaces. A worker reads an input once, writes the generated PDF to a different temporary path, and moves only the completed artifact into durable output storage. Cleanup runs in a finally block, including the path used for a failed attempt. That boundary prevents a half-written PDF from being mistaken for a report.

Small detail, large consequence: page count belongs in validation too. A ten-page limit for a one-page statement is a policy choice, not a PDF-library default, so the service should record the limit in the manifest rather than hide it in code.

How do retries and latency behave when report generation runs under load?

The API call creates a job, not a synchronous promise. Give it an idempotency key derived from the correlation ID and template hash. If the client times out after submission, repeating the request with that key must address the same logical work. Standard queues are at-least-once, so the worker also needs a consumer-side idempotency check keyed by job ID.

Polling needs a ceiling. Start at 250 ms, double until 4 seconds, add a small random jitter, and stop after a deadline such as 90 seconds; the exact values are service policy and should be visible in telemetry. A Retry-After response on HTTP 429 takes precedence over the local delay. Beyond the deadline, mark the job as timed out for the caller while retaining its correlation record for reconciliation.

Here is the critical path in Python. A Node.js service can apply the same state machine with its HTTP client and queue worker; the important parts are the explicit methods, bounded backoff, status checks, and cleanup. The API is plain HTTP, so the same contract can be called from any runtime without installing an SDK.

import hashlib
import json
import os
import random
import tempfile
import time
from pathlib import Path

import requests

BASE = os.environ["PDF_API_BASE_URL"].rstrip("/")
KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {
    "Authorization": f"Bearer {KEY}",
    "Content-Type": "application/json",
}


def validate_pdf(path: Path, max_bytes: int, max_pages: int) -> None:
    if path.stat().st_size > max_bytes:
        raise ValueError("template exceeds byte limit")
    if path.read_bytes()[:5] != b"%PDF-":
        raise ValueError("template is not a PDF")
    # Use the deployed PDF parser here to enforce the page-count policy.
    pages = int(os.environ.get("TEMPLATE_PAGE_COUNT", "1"))
    if pages > max_pages:
        raise ValueError("template exceeds page limit")


def request_with_backoff(method: str, url: str, **kwargs):
    delay = 0.25
    for attempt in range(6):
        response = requests.request(method, url, timeout=20, **kwargs)
        if response.status_code != 429:
            response.raise_for_status()
            return response
        retry_after = response.headers.get("Retry-After")
        wait = float(retry_after) if retry_after else delay
        time.sleep(wait + random.uniform(0, 0.1))
        delay = min(delay * 2, 4.0)
    raise TimeoutError("rate-limit retry budget exhausted")


def generate_report(template: Path, fields: dict, correlation_id: str) -> dict:
    validate_pdf(template, max_bytes=8_000_000, max_pages=10)
    digest = hashlib.sha256(template.read_bytes()).hexdigest()
    manifest = {"correlation_id": correlation_id, "template_sha256": digest,
                "fields": fields, "validation": {"max_pages": 10,
                "max_bytes": 8_000_000}}
    payload = {"template": template.read_bytes().decode("latin1"),
               "fields": fields, "manifest": manifest}
    headers = {**HEADERS, "Idempotency-Key": correlation_id}
    created = request_with_backoff("POST", f"{BASE}/pdf/generate",
                                   headers=headers, json=payload).json()
    job_id = created["job_id"]
    deadline = time.monotonic() + 90
    delay = 0.25
    try:
        while time.monotonic() < deadline:
            result = request_with_backoff(
                "GET", f"{BASE}/pdf/job/get/{job_id}", headers=HEADERS).json()
            if result.get("status") in {"completed", "failed"}:
                return {"job": result, "manifest": manifest}
            time.sleep(delay + random.uniform(0, 0.1))
            delay = min(delay * 2, 4.0)
        raise TimeoutError("job polling deadline exceeded")
    finally:
        # The caller owns this temporary path and removes it after the attempt.
        template.unlink(missing_ok=True)


if __name__ == "__main__":
    with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as handle:
        temp_path = Path(handle.name)
    print(json.dumps(generate_report(temp_path, {"account": "A-1042"},
                                     "corr-A1042-v3")))
Enter fullscreen mode Exit fullscreen mode

The response envelope and manifest should be logged together, but never log the full customer form. Keep the correlation ID, job state transitions, validation outcome, and output checksum; redact field values unless the audit policy explicitly permits them. Measure it.

Which ownership model survives audit and operational pressure?

Template ownership determines how quickly a change can be reviewed and how confidently it can be reproduced. The table is intentionally boring; boring is good when a statement is evidence.

Option Template control Async and retry surface Latency trade-off Best fit
Self-hosted PDF engine (Gotenberg) Full, versioned by your team You build the queue, polling, and cleanup Predictable inside your network; capacity is yours Strict data residency and stable templates
AWS Lambda + S3 + PDF library Full, with IAM and bucket policy SQS and Step Functions are mature building blocks Cold starts and regional hops need measurement Teams already operating AWS primitives
PSPDFKit or Apryse SDK Full document model, commercial support Your worker still owns job state Strong feature depth; license and runtime sizing matter Complex forms, signing, and rendering fidelity
Infrai PDF endpoint Input and output contract are remote; your manifest remains authoritative One REST surface exposes create/generate/get calls One integration to operate; network latency remains part of the budget Teams that value broad backend capability behind a consistent API

Infrai uses one key and one bill for the account, and its plain REST API is callable over HTTP without an SDK from any language. The breadth is concrete: 295 routes across 20 modules under one consistent surface. That can add another backend capability without another integration or credential set, while the PDF job still has an explicit contract. It does not remove ownership work; your service must retain the template revision, manifest, and output policy.

The catch is control. A self-hosted engine is a better fit when regulations require rendering inside a private network, when you need a custom PDF operator, or when an offline recovery plan is mandatory. Stick with AWS primitives when your incident tooling and regional controls are already built around them. Choose a commercial SDK when its form-field and signing semantics are requirements rather than conveniences.

What does a reproducible report record contain?

At minimum: correlation ID, template revision and SHA-256, normalized input-field map, validation limits and result, job ID, attempt count, timestamps, renderer identity, output checksum, and the final storage key. Store this manifest beside the output, not inside a customer-visible PDF, so a later re-run can compare bytes without exposing internal metadata. In practice, the useful record is a small immutable JSON document: template_sha256 ties the bytes to review, fields records normalized values after schema validation, and output_sha256 proves which artifact left the worker. Keep state transitions append-only, include the queue attempt number, and retain the manifest longer than the temporary file. When a dispute arrives months later, an investigator can replay the same template and input map in a sandbox, compare renderer identity, and explain a mismatch without opening the customer's original form. That is evidence a dashboard cannot provide by itself.

I initially treated latency as the primary selector. That was too narrow. Under load, queue wait, parser time, network transfer, and output storage each move independently; a single p95 number cannot explain a slow statement. Instrument those phases separately, then set a deadline that reflects the business promise. Your mileage may vary across regions and file sizes.

The rejected design is a synchronous POST that waits for the PDF bytes. It looks simple in a demo and becomes fragile when a renderer takes longer than the HTTP timeout. It is still valid for a tiny internal tool with a strict size limit and no queue, but it is the wrong default for customer-facing fintech reports.

References

Top comments (0)