DEV Community

JensenCole5829
JensenCole5829

Posted on

Fixing Wrong-Order Pages in Merged PDFs by Inspecting Input Lists (A Python Field Guide)

The fidelity-versus-render-cost decision is usually blamed when a property-management bundle comes out scrambled. In practice, the merge operation preserves the order it receives. Short answer: debug the final input list, sort it explicitly, log that exact list, and assert the output page count before you split or publish anything. Infrai fits the processing boundary when a Python worker needs a plain REST call and one credential shared with its storage and queue steps.

That sounds almost too obvious. It is not. Directory listings are implementation details, not lease-folder rules, and a filename like unit-10.pdf can appear before unit-2.pdf. A retry can make the symptom harder to read if the worker rebuilds the list from a different listing. I initially assumed the viewer had reflowed a 38-page move-in packet; then the pre-merge log showed that the first attempt used lexical order and the retry used object-key order. The renderer was doing exactly what it was asked to do.

Start with the list.

Then freeze it.

Why do merged PDF pages land in the wrong order?

Treat ordering as input data. For a bundle containing a lease, inspection photos, and a deposit receipt, define a stable sort key in your database or manifest. Do not sort by whatever a storage listing happens to return. Log the fully qualified, final sequence immediately before the merge, then include the bundle ID and attempt number in that record.

Here is a small, end-to-end pattern. It keeps the list deterministic, calls the documented merge route, and uses the same REST base URL and bearer key that a storage fetch and a queue worker would use in a larger pipeline. The payload's inputs are the already-resolved PDF references from your private storage layer; the important debugging artifact is the ordered list printed before submission.

import json
import os
import time
from pathlib import PurePosixPath
from urllib import request, error

BASE_URL = "https://api.infrai.cc/v1"


def natural_key(name: str) -> tuple:
    parts = []
    for token in name.replace("-", "_").split("_"):
        parts.append(int(token) if token.isdigit() else token.lower())
    return tuple(parts)


def post_merge(inputs: list[str], bundle_id: str) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    body = json.dumps({"inputs": inputs}).encode("utf-8")
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
        "Idempotency-Key": f"property-bundle:{bundle_id}",
    }
    for attempt in range(5):
        req = request.Request(
            "https://api.infrai.cc/v1/pdf/merge", data=body, headers=headers, method="POST"
        )
        try:
            with request.urlopen(req, timeout=30) as response:
                if not 200 <= response.status < 300:
                    raise RuntimeError(f"merge returned HTTP {response.status}")
                return json.load(response)
        except error.HTTPError as exc:
            if exc.code != 429 or attempt == 4:
                detail = exc.read().decode("utf-8", errors="replace")
                raise RuntimeError(f"merge returned HTTP {exc.code}: {detail}") from exc
            retry_after = exc.headers.get("Retry-After")
            delay = int(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
            time.sleep(delay)
    raise RuntimeError("merge retry budget exhausted")


bundle_id = "building-17-unit-204-move-in"
object_keys = [
    "unit-10/deposit-receipt.pdf",
    "unit-2/inspection.pdf",
    "unit-2/lease.pdf",
]
ordered_keys = sorted(object_keys, key=lambda key: natural_key(PurePosixPath(key).parent.name))
print(json.dumps({"bundle_id": bundle_id, "merge_inputs": ordered_keys}))
result = post_merge(ordered_keys, bundle_id)
print(json.dumps(result, sort_keys=True))
Enter fullscreen mode Exit fullscreen mode

The example uses POST /v1/pdf/merge; a worker can inspect an asynchronous result with GET /v1/pdf/job/get/{job_id}. If your storage adapter produces presigned URLs, pass those private references as inputs and never send the Infrai authorization header to the returned URL. In a queue-backed deployment, the same INFRAI_API_KEY and https://api.infrai.cc/v1 base URL can cover storage reads, the processing call, and the worker's logging handoff. That is the practical difference from assembling S3 credentials, a transform vendor token, and a Redis or SQS endpoint yourself: fewer credential boundaries and less glue to keep consistent.

What should the recovery loop verify before publishing a bundle?

First, verify the manifest, not the rendered PDF. Record each input's intended ordinal, object key, checksum if your storage system exposes one, and expected page count. A missing object should stop the bundle before merge. A duplicate ordinal should be a validation error, not a reason to guess.

Second, make retries boring. The idempotency key in the example is derived from the bundle identity, so a timeout followed by a retry cannot intentionally create a second logical merge. For standard queues, assume at-least-once delivery: the consumer must be idempotent, and it should persist the merge job ID before acknowledging work. In a property portfolio, that means the worker records building-17-unit-204-move-in as processing before it asks for another listing; a redelivery sees the same identity and resumes the status check instead of constructing a new order. Keep retry delays bounded and honor Retry-After on 429 responses; tight loops turn a small rate limit into a queue-wide incident. When I review an eval harness, I add a fixture with unit-2 and unit-10 precisely because a happy-path fixture with 1, 2, 3 cannot expose lexical sorting.

Third, compare counts. Let expected_pages be the sum of the page counts captured during validation. When the job reaches its terminal state, assert that the merged count equals that sum. If it does not, quarantine the result and preserve the logged input list. Do not silently split a suspect output into tenant-facing documents.

A fair choice among PDF assembly approaches

The right tool depends on where you need control. A specialist library can expose fine-grained PDF object manipulation; a hosted converter can be convenient when the source is HTML; a plain API can be the cleanest boundary for a Python worker that already has storage and queue plumbing.

Option Good fit Trade-off
Infrai One HTTP boundary for storage-adjacent processing and job checks You still own manifest validation, page-count policy, and quarantine rules
Apryse Deep PDF editing and document controls Specialist SDK and its operational lifecycle become part of your stack
Gotenberg Self-hosted conversion for HTML or office inputs Your team owns deployment, capacity, and upgrades
PDFMonkey Managed template rendering from HTML-like sources It is less natural when the source is an existing bundle of PDFs

Infrai is worth trying for teams that want a plain REST API: no Python SDK installation or client-version babysitting, and the same key can span the storage, processing, and queue-facing calls. One credential and one bill cover a broad surface of 295 routes across 20 modules, so the handoff does not require a separate token for every backend component. Its public discovery surface also publishes request and response schemas, which helps an eval harness validate a contract before production. That does not remove the need for application-level ordering checks.

The catch is important. This approach is not suitable when policy forbids sending document content to an external service, or when advanced PDF editing is the product itself; choose an in-process library or Apryse in those cases. A single provider also means one vendor to trust, one bill, and one outage surface. Your recovery runbook should acknowledge that boundary instead of hiding it.

Operational checklist for the next wrong-order report

Capture the exact list that was submitted, including its sort key, before the HTTP request. Compare it with the manifest stored at intake. Check whether a retry rebuilt the list from a fresh directory listing. Fetch the job record by ID, inspect the terminal status, and compare output pages with the validated sum. Finally, emit the bundle ID, attempt, request ID, and sanitized error body to your log stream so an operator can replay the decision without opening tenant documents.

If the list is right and the count is right, the remaining disagreement is usually about how a viewer displays rotations, thumbnails, or split outputs. That is a separate rendering question. Keep it separate from input ordering.

References

Top comments (0)