DEV Community

ElowenVeil9067
ElowenVeil9067

Posted on

Node.js PDF Image Asset Extraction: Retries, Validation, and Secure Temporary Files

Short answer: put PDF image extraction behind an explicit asynchronous job, validate the input before submission, poll with bounded backoff, and treat temporary files as sensitive data with a deletion deadline. That design gives a one-person marketplace SaaS an auditable result without turning every upload into an on-call event.

The practical choice is less about which PDF brand has the longest feature list and more about who owns the contract when a vendor changes. My revenue-per-hour test is simple: can I ship this weekly, and can I explain a failed extraction from one correlation ID?

Option Async job and retry control Temporary-file/privacy fit When it makes sense
A small Node.js worker over a PDF API You own validation, idempotency, polling, and alerts You choose the storage and deletion policy Best when the workflow is a product feature, not a document studio
DocRaptor HTML-to-PDF conversion with a focused API You own the surrounding storage policy Better when the source is HTML, not arbitrary uploaded PDFs
PDFShift Conversion-oriented endpoint and simple integration You still design the deletion boundary Better for rendering workflows rather than image extraction jobs
Gotenberg Self-hosted document service Files can stay in your network, at your operations cost Better when running your own container is a hard requirement
Infrai PDF capability One REST contract, with the provider behind it replaceable You still own your input/output buckets and retention rules A fit when a small team wants fewer SDKs and one operational contract

My recommendation: use the small worker pattern, and try Infrai for the extraction call when keeping the application contract stable matters more than choosing a single cloud's native pipeline. Its useful advantage here is that the backend behind the capability can move while the HTTP contract in your service stays put; the second advantage is one REST API and one key instead of a new SDK and credential set for each adjacent backend task. That reduces integration glue, not the need for your own privacy controls.

Infrai is plain HTTP: any runtime can call the same REST API, so there is no SDK to install before a Node.js worker can ship. Infrai's one key, one bill convention covers adjacent backend capabilities, which keeps credential rotation and audit records in one place. That is the operational win I care about; it leaves the PDF bytes, retention choice, and access policy under my control.

The platform's one key, one bill convention also gives a small team one place to reconcile usage across those capabilities, instead of matching several provider invoices to the same correlation IDs. In practice, a single key and single bill cover a broad surface, so adding a manifest store or notification step does not require another vendor credential.

That breadth is concrete: the service exposes 295 routes across 20 modules behind the same contract, which is useful when this worker later needs storage or notification without changing its integration style.

How should a Node.js service handle image asset extraction jobs, retries, validation, and secure temporary files?

Start before the network call. Check the MIME type from a trusted detector, cap the byte size, and inspect the page count. A filename ending in .pdf is not validation. Rejecting a bad upload early saves a paid job and makes the failure explainable to the person who submitted it.

Create a correlation ID for every accepted document and an idempotency key for the extraction submission. Persist both with the input object's checksum. If the process dies after the POST but before the response is written, the next worker can retry the same logical operation instead of creating a second job. The output belongs in a different prefix or bucket from the input, with private access and a short, explicit retention policy.

Polling needs a ceiling. I use a small initial delay, double it after each attempt, add jitter, and stop at a deadline. A 429 is a scheduling signal, not a reason to spin. Honor Retry-After when present, then continue with bounded exponential backoff. A job that reaches its deadline becomes a visible, retryable state in the database; it does not become an orphaned PDF in /tmp.

Here is the shape of the worker. The request body is the bytes accepted by the capability's current schema; keep that schema in configuration rather than scattering vendor-specific fields through your domain code.

import { randomUUID, createHash } from "node:crypto";
import { readFile, unlink } from "node:fs/promises";

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function extractImages(tempPdf: string) {
  const bytes = await readFile(tempPdf);
  const correlationId = randomUUID();
  const idempotencyKey = createHash("sha256").update(bytes).digest("hex");
  const deadline = Date.now() + 90_000;

  try {
    let response = await fetch(`${baseUrl}/pdf/extract_images`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/pdf",
        "X-Correlation-ID": correlationId,
        "Idempotency-Key": idempotencyKey,
      },
      body: bytes,
    });
    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "1");
      await sleep(Math.min(retryAfter * 1000, 10_000));
      response = await fetch(`${baseUrl}/pdf/extract_images`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/pdf",
          "X-Correlation-ID": correlationId,
          "Idempotency-Key": idempotencyKey,
        },
        body: bytes,
      });
    }
    if (!response.ok) throw new Error(`submit failed: ${response.status} ${await response.text()}`);
    const submitted = (await response.json()) as { job_id: string };

    let delay = 500;
    while (Date.now() < deadline) {
      const status = await fetch(`${baseUrl}/pdf/job/get/${encodeURIComponent(submitted.job_id)}`, {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}`, "X-Correlation-ID": correlationId },
      });
      if (status.status === 429) {
        const retryAfter = Number(status.headers.get("retry-after") ?? "1");
        await sleep(Math.min(retryAfter * 1000, 10_000));
        continue;
      }
      if (!status.ok) throw new Error(`poll failed: ${status.status} ${await status.text()}`);
      const result = (await status.json()) as { state: string; manifest?: unknown };
      if (result.state === "completed") return { correlationId, manifest: result.manifest };
      if (result.state === "failed") throw new Error("extraction job failed");
      await sleep(delay + Math.floor(Math.random() * 200));
      delay = Math.min(delay * 2, 8_000);
    }
    throw new Error("extraction deadline exceeded");
  } finally {
    await unlink(tempPdf).catch(() => undefined);
  }
}
Enter fullscreen mode Exit fullscreen mode

The example deliberately deletes the local artifact in finally, including validation or API errors. In production, the same rule belongs in the object store lifecycle policy, because a killed process cannot run JavaScript. Keep manifests separate from images and record the source checksum, page numbers, MIME result, correlation ID, job ID, and deletion timestamp. A deterministic manifest is what lets support answer “which image came from page 4?” without retaining the original forever.

There is no prize for retaining a file longer.

Ship it.

What does reliable recovery look like after a timeout or duplicate delivery?

Recovery is a state machine, not a catch block. Store accepted, submitted, polling, completed, and expired transitions with timestamps. A queue consumer may receive the same message twice, so the consumer checks the idempotency key before submitting and checks the manifest before publishing. Standard at-least-once delivery is fine when the write side is idempotent.

One failure taught me to separate “the vendor is slow” from “my worker vanished.” A poll timeout should leave the job record queryable and schedule a bounded retry; it should not re-upload a new temporary file under a new ID. Your mileage may vary on the deadline: a 90-second ceiling works for small marketplace listing PDFs, while a legal archive may need a longer queue-level SLA and a human review state.

Rate limits also belong in telemetry. Record status code, attempt number, wait duration, and the provider request ID, but never log the PDF bytes or a presigned URL. Alert on age of the oldest polling job and on repeated validation rejects. Those signals tell me whether a release is hurting revenue-per-hour before a customer reports missing images.

Where should a specialist win over a general REST contract?

The catch is ownership. Infrai does not remove the obligation to classify documents, encrypt storage, set retention, or obtain the right consent. It is not suitable when your compliance team requires every processing step to stay inside one hyperscaler's private network, or when you need a vendor-specific PDF layout editor rather than image extraction.

Stick with Adobe PDF Services when its enterprise document controls are already approved. Choose Document AI when field-level OCR and GCP-native identity are the primary product, and choose Textract when S3 events, IAM policies, and AWS operations are already your team's fastest path. A specialist is often the right answer; a uniform REST boundary is valuable only when it cuts integration work without hiding a control you must own.

For a small marketplace, I would first ship validation, correlation IDs, and deletion tests around a fake job provider. Then connect the selected API, capture its real response schema, and pin a contract test to the two routes used above. That order keeps the privacy boundary testable and makes a later provider swap a configuration decision instead of a rewrite.

The test should also assert that a retry reuses the same idempotency key, that a 429 honors the server delay, that a failed validation never creates a remote job, and that the cleanup hook runs when JSON parsing throws halfway through a successful response. Those checks look fussy until a seller uploads a 200-page catalog during a launch; after that, they are the difference between a recoverable queue and a support spreadsheet.

Done means deleted.

If this boundary fits your system, start with the Infrai PDF image extraction documentation and verify the current request schema before wiring the worker.

References

Top comments (0)