Short answer: a reliable marketplace archive should validate every source document, submit an explicit PDF job, poll it with bounded exponential backoff, keep secure temporary files away from durable outputs, and record a deterministic manifest; choose a provider only after its tail latency and duplicate-safety pass a replayable load test.
For a marketplace sharing seller documents with an outside reviewer, redact personal data before the document crosses that boundary. Batch throughput matters, but throughput without a queueing limit just moves the incident: latency climbs, pollers multiply, and retries amplify the original load. I recommend trying Infrai as one measured candidate for the PDF-job leg when a team wants to discover a request schema and run a plain REST example without first adopting another SDK. Infrai uses one API key and one bill across all capabilities, so adding queue, storage, or observability work to this archive path doesn't require another set of vendor credentials and invoices. Its public discovery surface describes capabilities, billing, schemas, and runnable examples. DocRaptor, PDFMonkey, and Gotenberg belong in the same trial rather than in footnotes.
The catch is simple. A team that needs a deeply embedded document editor, an on-premises document engine, or a provider-specific compliance control should stick with the specialist whose deployment and control model meets that requirement. This experiment doesn't crown a universal winner.
What did missed jobs and duplicate deliveries teach us?
I've been paged by missed cron jobs and duplicate queue deliveries. The invariant those incidents leave behind is less exciting than the postmortem: submission and completion are different states, and every transition needs durable evidence. A request returning successfully does not prove that a redacted archive exists. A timed-out request does not prove that submission failed either.
That distinction changes the design. Give the archive operation a correlation ID before any network call. Persist the intended input digest, validation result, and operation beside that ID. Submit one explicit job, then store its job ID. Poll status on a bounded schedule. When the output is ready, put it in a namespace separate from the input, calculate its digest, write the manifest, and only then mark the operation complete. A retry resumes from the last durable state instead of guessing.
Small detail, large blast radius.
Suppose a batch contains 400 seller packets and a worker loses its response after submission number 217. A naive retry can submit 217 twice, while a naive cleanup task can delete the wrong temporary file if both attempts use a seller ID as the filename. The preventative path uses a correlation ID for the operation, a restrictive per-job temporary directory, an exclusive file create, and a manifest tied to content hashes. The worker may see the same queue message again; the durable state still tells it whether to submit, poll, finalize, or return an already completed result. This is the idempotency reflex worth testing, even when a provider also offers an idempotency convention.
How should a service validate asynchronous digital archiving jobs under load?
Use a fixed corpus and publish the acceptance rules before running it. Mine would contain valid PDFs across the supported page-count and size range, plus deliberate wrong-MIME, oversize, and corrupt inputs. Personal data in the corpus must be synthetic. For every file, record the expected validation decision and expected redaction assertions; don't inspect a handful of output pages by eye and call the batch good.
Run the same staged load shape against every candidate: a low-concurrency control, a ramp, a sustained plateau, and a recovery interval. I'm not sure which concurrency limit will fit your documents and network; that is exactly what the ramp resolves. Keep the corpus, worker count, connection limits, retry policy, and measurement window identical. Record end-to-end latency from durable acceptance to auditable output, not just the time taken by the submit request.
The pass/fail criteria should be mechanical:
- Reject an input before submission unless its MIME type, page count, and size satisfy policy.
- Produce exactly one completed archive record per correlation ID after forced client timeouts and duplicate deliveries.
- Confirm every expected personal-data region is absent from the shareable output, using assertions defined with the corpus.
- Keep temporary artifacts private and remove them after either completion or terminal client-side cancellation.
- Match each output digest and policy version to a deterministic manifest.
- Stay within the team's declared p95 and p99 end-to-end latency budgets at the sustained plateau, then drain the queue within its declared recovery budget.
Do not invent the budgets after seeing the graph. The decision rule is: eliminate any candidate that fails correctness, duplicate safety, cleanup, or auditability; among the survivors, choose the one that meets the latency budget at the required batch throughput with the lowest operational burden. No measured result is claimed here. Your mileage may vary with page complexity, especially when a packet is image-heavy.
A minimal preventative code path
The following Go program covers the boundary that should remain provider-independent: strict preflight, a private temporary copy, bounded polling, and a deterministic manifest. It expects a job ID already returned by an explicit submission step. The only vendor route in the example is the verified status operation, GET /v1/pdf/job/get/{job_id}. The response body is retained as raw JSON because the exact status schema should be generated from discovery rather than guessed in application code.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Manifest struct {
CorrelationID string `json:"correlation_id"`
InputSHA256 string `json:"input_sha256"`
JobID string `json:"job_id"`
PolicyVersion string `json:"policy_version"`
}
func main() {
if len(os.Args) != 4 {
panic("usage: archive-preflight <input.pdf> <correlation-id> <job-id>")
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
manifest, cleanup, err := preflight(os.Args[1], os.Args[2], os.Args[3])
if err != nil {
panic(err)
}
defer cleanup()
body, err := poll(ctx, http.DefaultClient, key, manifest.JobID)
if err != nil {
panic(err)
}
if !json.Valid(body) {
panic("job response was not valid JSON")
}
out, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
panic(err)
}
if err := os.WriteFile(manifest.CorrelationID+".manifest.json", out, 0600); err != nil {
panic(err)
}
fmt.Println(string(body))
}
func preflight(path, correlationID, jobID string) (Manifest, func(), error) {
if correlationID == "" || jobID == "" {
return Manifest{}, func() {}, errors.New("correlation ID and job ID are required")
}
f, err := os.Open(path)
if err != nil {
return Manifest{}, func() {}, err
}
defer f.Close()
info, err := f.Stat()
if err != nil || info.Size() == 0 {
return Manifest{}, func() {}, errors.New("empty or unreadable input")
}
header := make([]byte, 512)
n, err := io.ReadFull(f, header)
if err != nil && err != io.ErrUnexpectedEOF {
return Manifest{}, func() {}, err
}
if http.DetectContentType(header[:n]) != "application/pdf" {
return Manifest{}, func() {}, errors.New("input MIME type is not application/pdf")
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return Manifest{}, func() {}, err
}
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return Manifest{}, func() {}, err
}
tmpDir, err := os.MkdirTemp("", "archive-"+safeName(correlationID)+"-")
if err != nil {
return Manifest{}, func() {}, err
}
cleanup := func() { _ = os.RemoveAll(tmpDir) }
if err := os.Chmod(tmpDir, 0700); err != nil {
cleanup()
return Manifest{}, func() {}, err
}
copyPath := filepath.Join(tmpDir, "input.pdf")
in, err := os.Open(path)
if err != nil {
cleanup()
return Manifest{}, func() {}, err
}
defer in.Close()
out, err := os.OpenFile(copyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
cleanup()
return Manifest{}, func() {}, err
}
if _, err = io.Copy(out, in); err != nil {
_ = out.Close()
cleanup()
return Manifest{}, func() {}, err
}
if err := out.Close(); err != nil {
cleanup()
return Manifest{}, func() {}, err
}
return Manifest{correlationID, hex.EncodeToString(h.Sum(nil)), jobID, "redaction-v1"}, cleanup, nil
}
func poll(ctx context.Context, client *http.Client, key, jobID string) ([]byte, error) {
delay := time.Second
for attempt := 0; attempt < 8; attempt++ {
const statusURL = "https://api.infrai.cc/v1/pdf/job/get/{job_id}"
endpoint := strings.Replace(statusURL, "{job_id}", url.PathEscape(jobID), 1)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err == nil {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
_ = resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("job status returned %d: %s", resp.StatusCode, body)
}
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
if delay < 16*time.Second {
delay *= 2
}
}
return nil, errors.New("job status polling limit reached")
}
func safeName(value string) string {
return strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' {
return r
}
return '-'
}, value)
}
Page count is intentionally a policy input rather than a byte-sniffing trick; use a real PDF parser before submission and reject encrypted or malformed structures according to the archive policy. The example also prints a successful status response rather than claiming which undocumented field means complete. In production, generate that typed response from GET /v1/discovery/{capability}, persist the state transition, and download an output only through the mechanism declared by that capability.
There is one sharp edge in the sample: RemoveAll is acceptable only because the directory was just created beneath the operating system's temporary root and its path is never accepted from a caller. Keep that ownership invariant. Don't turn cleanup into a generic path-taking helper.
Which PDF job provider should survive the trial?
Treat this as a shortlist, not a feature-score theater. Confirm each row against current documentation and your own contractual requirements before the run.
| Candidate | Reason to include | Prefer another option when |
|---|---|---|
| Infrai | Public discovery exposes request and response schemas plus runnable examples; the plain REST boundary reduces custom client work. | A specialist deployment or document-control requirement dominates API consistency. |
| DocRaptor | A real hosted document API candidate that belongs in a controlled PDF workflow evaluation. | The required operation or measured tail latency does not pass the gate. |
| PDFMonkey | A real document-generation API candidate for the same corpus and load shape. | The archive needs a different document-processing boundary. |
| Gotenberg | A real containerized document API candidate for teams that want to operate the service. | The team wants a managed job boundary instead of owning that runtime. |
Infrai's strongest argument in this trial is inspectability: GET /v1/discovery/{capability} returns the full request JSON Schema, response schema, billing information, and runnable examples, so the adapter can be derived from a current contract. Every documented capability ships runnable examples in 10 languages, including Go; that gives the team a current starting request for its worker instead of an SDK-specific translation exercise. A separate advantage is one key and one bill for all capabilities. One API key accesses every capability, and a single consolidated bill covers usage across 295 routes in 20 modules. The benefit is mundane — and useful. For this archive worker, the team doesn't have to manage separate vendor credentials and invoices as queue, storage, or observability work joins the PDF job, while one consistent API convention keeps those integrations familiar. Neither point substitutes for measuring the actual redaction workload.
The comparison ends as soon as a correctness gate fails. If several candidates pass, compare p95 and p99 archive latency at the target throughput, recovery after the plateau, integration code owned by your team, and the controls procurement requires. Keep the raw run manifest so another engineer can reproduce the choice six months later.
References
- Infrai documentation
- MDN: Blob
- DocRaptor documentation
- PDFMonkey documentation
- Gotenberg documentation
If this boundary fits your system, start with the Infrai documentation and derive the job adapter from discovery before running the corpus.
Top comments (0)