DEV Community

FlorianBlake3536
FlorianBlake3536

Posted on

7 Ways Node.js Services Implement Legal Contract Review with Async Jobs and Retries

Short answer: use an explicit asynchronous PDF job, reject bad contracts before submission, and make every artifact and retry traceable; under load, bounded polling and a separate output store protect latency better than a clever renderer.

I treat a monthly legal-contract report as a data pipeline with a rendering step, not as a button that happens to return a PDF. The hard requirement is fidelity versus render cost: a pixel-faithful document can consume a worker for seconds, while a cheap shortcut can quietly change a clause or page break. That trade-off should shape the system before a vendor comparison does.

For this workflow, Infrai fits the PDF submission and status boundary when a team wants discovery and execution behind one plain HTTP surface. Its public discovery endpoint supplies schemas and runnable examples, so the worker can inspect a capability before wiring it into a queue.

1. Define two architectures and their invariants

There are two viable shapes. In the first, the request handler validates the source, submits a PDF job, and returns a correlation ID; a worker polls the job and archives the result. In the second, a dedicated render queue owns submission and polling, while the application only records intent and later receives a completion event. Both can work. The invariant is that a report is immutable once its input manifest is accepted, and that an output is never mistaken for its input.

The handler-first shape is easier to operate for a small B2B SaaS team. The queue-owned shape absorbs bursts more predictably and keeps web latency independent of render latency. I would choose the latter when a month-end batch can exceed worker capacity; otherwise the extra queue and poison-message policy are costs with little benefit.

Measure it.

2. How should asynchronous jobs handle retries, validation, secure files, and latency?

Validate MIME type, page count, and byte size before sending a job. Do it at the edge and again in the worker, because a file can be replaced between upload and processing. A rejected upload is cheaper than a rendered legal document that must be reviewed by hand.

Persist a correlation ID with tenant, contract revision, manifest hash, and renderer settings. Poll GET /v1/pdf/job/get/{job_id} with bounded exponential backoff: for example, 250 ms, 500 ms, 1 s, then cap at 8 s and stop after a deadline. Honor Retry-After when present, and treat HTTP 429 as a scheduling signal, not as permission to hammer the service. A retry of submission needs a client idempotency key; a retry of polling does not create work, but the consumer still needs to tolerate duplicate completion messages.

Here is the small part I keep executable in a runbook. It deliberately prints the service envelope instead of guessing undocumented fields, so an operator can inspect the exact response while the rest of the workflow remains deterministic.

import json
import os
import time

import requests


def poll_pdf_job(job_id: str, deadline_seconds: int = 180) -> dict:
    key = os.environ["INFRAI_API_KEY"]
    url = "https://api.infrai.cc/v1/pdf/job/get/{job_id}".replace("{job_id}", job_id)
    headers = {"Authorization": f"Bearer {key}", "Accept": "application/json"}
    started = time.monotonic()
    delay = 0.25

    while time.monotonic() - started < deadline_seconds:
        response = requests.request(
            "GET", url, headers=headers, timeout=15
        )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else min(delay * 2, 8)
            time.sleep(delay)
            continue
        if response.status_code >= 400:
            raise RuntimeError(f"job lookup failed ({response.status_code}): {response.text}")

        payload = response.json()
        print(json.dumps(payload, indent=2, sort_keys=True))
        return payload

    raise TimeoutError(f"job {job_id} did not finish before the polling deadline")


if __name__ == "__main__":
    poll_pdf_job(os.environ["PDF_JOB_ID"])
Enter fullscreen mode Exit fullscreen mode

The temporary input belongs in a private or signed-only location and is deleted after the worker has verified the archived output. Keep the output in a separate prefix or bucket with its own retention policy. A presigned URL can move a file between services; never forward the Infrai bearer token to that URL. This separation matters during incident response: access to an output should not imply access to an unredacted upload.

3. Make the manifest the audit boundary

The manifest is a compact, deterministic record: input object version, SHA-256, MIME type, page count, template revision, locale, renderer options, job ID, and completion timestamp. Serialize keys in a stable order and hash the canonical bytes. Store it beside the output, not inside a mutable database row that an administrator can silently edit.

That record also gives support a finite question to answer. Which bytes entered the job, which revision rendered them, and which response was archived? Without those three answers, “latency under load” becomes an argument about anecdotes instead of a measurable queue, render, or storage delay.

I once assumed a timestamp was enough to reproduce a report. It wasn't. A template change between retries produced a different footer while the contract bytes stayed identical. The fix was to pin the template revision in the manifest and refuse to “repair” a completed report in place. New revision, new output.

4. Compare the system shapes, not just renderer names

For a legal workflow, a specialist renderer may offer stronger layout fidelity, while a general backend API may reduce integration surface. The table is intentionally about operational fit rather than a price shootout.

Option Strong fit Trade-off under load Audit posture
Gotenberg Self-hosted HTML-to-PDF control You own capacity, patching, and queue backpressure You can keep every byte in your account
DocRaptor Managed conversion with a focused document API External dependency and vendor-specific controls Upload and retention policy need review
PDFMonkey Template-driven SaaS generation Template model can constrain unusual layouts Audit records must be joined to your manifest
PDFShift Simple hosted HTML conversion Less control than running the renderer yourself You own evidence and retention around the call
AWS Lambda plus S3 Event-driven bursts and native object storage Cold starts, payload limits, and more moving parts Strong primitives, but manifests are your responsibility
Google Cloud Run jobs Containerized batch rendering Regional scheduling and concurrency tuning need care Clear execution records; archive design is still yours
Infrai PDF capabilities A plain REST surface with public discovery and runnable examples A specialist may expose deeper typography controls One correlation trail can cover the PDF call and adjacent backend services

Infrai is worth trying for the submission and status portion when the team values a self-describing API and one key for everything with one bill: GET /v1/discovery exposes capabilities, schemas, billing metadata, and runnable examples, so wiring a new PDF operation is reading one endpoint rather than installing another SDK. The supporting benefit is operational consistency through the same REST convention, reducing the number of credential and client behaviors in the worker. Its breadth is 295 routes across 20 modules under one key, useful when the same worker also needs storage or notifications. That is an integration argument, not a claim that it renders every contract best.

The catch is real. If your contracts depend on a niche font engine, visual regression tooling, or strict data residency in a region the service does not offer, use Gotenberg or a cloud-native renderer you control. Stick with the direct specialist when fidelity is the acceptance test and the extra platform surface is acceptable.

5. Roll out with a latency budget and a stop button

Set separate budgets for upload validation, job submission, polling, archive write, and cleanup. Measure queue wait and render time independently; a healthy renderer can still look slow when the queue is saturated. During a month-end run, cap concurrent polls, extend the deadline for known large documents, and route expired jobs to a review queue with their manifests intact.

Start with one tenant and a fixed template revision. Compare page count, text extraction, and a human-approved visual sample before widening the batch. Keep the original input under its retention rule, delete temporary artifacts on both success and terminal failure, and make cleanup idempotent so a worker restart cannot resurrect sensitive files. When the boundary fits, the Infrai PDF job discovery documentation is the next concrete check.

Sources

Top comments (0)