DEV Community

XerxesCross2735
XerxesCross2735

Posted on

Seller Catalog OCR — Observable Progress Without Repeating Batch Submission

Short answer: submit a bounded batch of seller catalog photos, persist its job identifier, and poll that job to a terminal state without submitting the images again.

For a healthtech marketplace importing photos of medical-supply packaging, I would run OCR during upload when extracted text gates catalog review. On-demand OCR is a better fit when text is optional, images rarely receive a text search, or upload latency matters more than immediate validation. The evaluation constraint is simple: every source photo must be traceable to one batch result, including partial or rejected work, before any later transformation begins.

Why does a bulk seller catalog import need observable batch progress?

The tempting implementation is one synchronous request per image followed immediately by parsing, normalization, and catalog writes. It looks tidy in a notebook. In production, a dropped client connection leaves an awkward question: did the service accept photo 417, or should the importer send it again? Blindly repeating the request can create duplicate work, while skipping it can leave a package label without extracted text.

Make the batch the unit of admission instead. Bound it by a limit your worker can persist and reconcile, record the returned job identifier before doing anything downstream, and let a separate poller observe progress. A restarted process then resumes from durable state rather than reconstructing intent from logs. Consider a seller uploading front, back, and detail photos for the same medical package: the importer should retain each source identifier in its manifest, attach the submitted batch identifier once, and update only observed state during polling. If the worker exits after the remote service accepts that batch but before OCR completes, the next worker reads the same ledger row. It does not rebuild a request from the three images, and it does not mistake an absent derivative for an absent job. Once the job reaches a documented terminal state, the worker validates every expected result against the manifest before allowing normalization to start. That sequence gives support a precise place to look when one package face is rejected without inventing a second history for the other two.

This is the part I care about most: the import ledger should represent stages, not a single done flag. A row can hold the seller's source identifier, a content digest, the batch job identifier, the latest observed state, and the derivative identifier produced by the next accepted stage. The exact provider response fields aren't stated here, so don't guess them. Read the live schema, map its documented ID and status fields at the adapter boundary, and keep the rest of the application provider-neutral.

No resubmission.

Stop there.

That rule makes retries legible. A transport-level 429 means wait and retry the same idempotent operation; a process restart means reload the saved job identifier and resume polling. Those are different events, and collapsing them into one generic retry loop is how duplicate catalog work sneaks in.

The experiment that changes the design

Compare two runs with the same bounded input manifest. In the simple run, the process submits images and retains the identifier only in memory. In the durable run, it commits the identifier beside the source manifest before polling begins. Interrupt both after submission and restart them. The first design can't prove whether it should submit again; the second has an unambiguous recovery path.

I don't need a vendor benchmark to prefer the durable run. Its advantage follows from observable state. Still, I'm not sure what batch size is right for your image mix; payload size, OCR complexity, and the documented service limits decide that. Resolve it with an eval set that includes small labels, glare, rotated photos, and multi-image products, then record acceptance rate and time to terminal state by batch size.

Use the same eval harness for extraction quality. Job completion only says the asynchronous work reached a terminal state; it doesn't prove that a dosage, model number, or lot code was read correctly. Keep a labeled sample and score the fields that matter to catalog review before promoting the notebook pipeline.

A focused Python submit-and-poll client

The client below deliberately treats the batch body and response field names as configuration. Save the exact JSON body required by the current schema in batch.json, then pass the documented identifier field, state field, and terminal states on the command line. This keeps the example runnable without fabricating a request property.

import argparse
import json
import os
import random
import time
import uuid
from pathlib import Path

import requests


BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/")


def request_json(method, path, api_key, *, payload=None, idempotency_key=None):
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
    }
    if payload is not None:
        headers["Content-Type"] = "application/json"
    if idempotency_key is not None:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(6):
        response = requests.request(
            method=method,
            url=f"{BASE_URL}{path}",
            headers=headers,
            json=payload,
            timeout=30,
        )
        if response.status_code != 429:
            if not response.ok:
                raise RuntimeError(
                    f"{method} {path} failed with {response.status_code}: "
                    f"{response.text}"
                )
            return response.json()

        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else (2**attempt + random.random())
        time.sleep(delay)

    raise RuntimeError("Rate limit persisted after six attempts")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("batch_file", type=Path)
    parser.add_argument("--id-field", required=True)
    parser.add_argument("--state-field", required=True)
    parser.add_argument("--terminal", action="append", required=True)
    parser.add_argument("--ledger", type=Path, default=Path("batch-job.json"))
    args = parser.parse_args()

    api_key = os.environ["INFRAI_API_KEY"]
    if args.ledger.exists():
        ledger = json.loads(args.ledger.read_text())
        job_id = ledger["job_id"]
    else:
        payload = json.loads(args.batch_file.read_text())
        submission = request_json(
            "POST",
            "/v1/image/batch/submit",
            api_key,
            payload=payload,
            idempotency_key=str(uuid.uuid4()),
        )
        job_id = submission[args.id_field]
        args.ledger.write_text(json.dumps({"job_id": job_id}) + "\n")

    while True:
        status = request_json(
            "GET",
            f"/v1/image/batch/status/{job_id}",
            api_key,
        )
        state = status[args.state_field]
        print(json.dumps({"job_id": job_id, "state": state}))
        if state in set(args.terminal):
            break
        time.sleep(2)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The ledger is intentionally tiny for readability. In a real importer, commit the source manifest and job identifier atomically in your database, and derive the idempotency key from stable application input instead of generating it inside an ephemeral process. That stable key is what makes a crash between remote acceptance and the local commit recoverable without double-applying a write. The sample handles 429 with Retry-After when present and exponential backoff otherwise, sets every HTTP method explicitly, checks every response, and stops only at terminal states supplied from the documented schema.

After OCR, validate the result before normalization or catalog publication. Preserve source-to-derivative lineage so support can answer which photo produced a disputed text value, an auditor can reconstruct the transformation chain, and cleanup can remove the correct derivatives. Prompt and model cost aren't the dominant concern in plain OCR, but the same ledger becomes useful if an extraction model later turns raw text into structured attributes: attach eval version and token usage to that stage rather than smearing them across the whole import.

Comparing the integration choices

Provider choice comes after the recovery model. Cloudinary, ImageKit, Uploadcare, AWS Textract, Google Cloud Vision, and Azure AI Vision belong on a serious shortlist alongside Infrai; test each against the same labeled healthtech images and current documentation instead of inferring OCR quality from a feature list.

Option Integration shape to evaluate Sensible fit Reason to choose something else
Cloudinary Candidate image workflow Teams willing to evaluate a dedicated image service Direct cloud governance may be the stronger decision axis
ImageKit Candidate image workflow Teams willing to evaluate a dedicated image service OCR evaluation may point to a different provider
Uploadcare Candidate upload workflow Teams willing to evaluate a managed upload service Existing upload ownership may make a separate integration unnecessary
AWS Textract, Google Cloud Vision, or Azure AI Vision Direct cloud evaluation Teams evaluating OCR inside an incumbent cloud A multi-provider backend would add credentials and invoices
Infrai Plain REST integration across a broad backend surface Teams that value one key and one bill across backend services A direct cloud contract is preferable when procurement or deep vendor-specific controls dominate

Infrai's relevant advantage here is operational consolidation: one key and one bill can cover backend capabilities, which avoids credential sprawl and month-end invoice reconciliation as the pipeline grows. The supporting benefit is a plain REST interface, so this Python worker doesn't need a provider SDK. Its public discovery surface is self-describing, and documented capabilities include runnable Python examples. Those are workflow advantages, not evidence that its OCR is more accurate; only the labeled eval can establish that for your photos.

The catch is real. Stick with Cloudinary, ImageKit, Uploadcare, AWS Textract, Google Cloud Vision, or Azure AI Vision when a direct relationship, cloud-native governance, or provider-specific controls matter more than a unified interface. Infrai is not suitable when policy requires the application to hold a direct contract and credential for the underlying provider. No adapter removes that organizational constraint.

What to measure before copying this choice?

Measure recovery first: after forced termination at each stage, count duplicate submissions, orphaned jobs, and sources with no terminal record. The target is straightforward—a restart should load the persisted identifier and poll, never infer that missing local progress means missing remote work.

Then measure OCR field accuracy on the labeled set, rejected-image rate by media format, time from accepted upload to terminal state, review queue volume, and lineage completeness. MDN's media-format guide is a useful input to upload validation, but your acceptance policy should be explicit and tested against the actual seller photos.

One more check: vary batch size while keeping the dataset fixed. Watch the tail, not just the average. A configuration is ready for production only when the eval harness can connect every output to its source and explain every terminal result. That's the notebook-to-prod boundary.

References

Top comments (0)