For an e-commerce return form that stays marked in progress, read the renderer's latest job status before retrying anything. Short answer: make failure and deadline expiry terminal outcomes in your own Postgres row, and retain the last observed status. A form-fill or flattening request may have ended unsuccessfully while the application's poller kept waiting. A deadline also covers the case where no terminal result is ever observed.
How do I debug a PDF job stuck in progress forever?
The row says what the poller recorded, not necessarily what rendering did. A worker can stop polling after a deployment, a status request can fail, or a poller can observe failure without persisting it. Those cases require different remedies. Check the remote job status, the time of the last successful observation, and the local deadline separately. Do not submit a second form-fill operation merely because the UI still says in progress; duplicate documents can then be attached to the same return.
For a return authorization packet, record the order's internal identifier, the requested output type, the remote job identifier if one exists, the last observed remote status, and a local deadline. Keep customer form fields out of status logs. An operator should be able to distinguish "no status fetched" from "status fetched but not committed." That distinction matters more than another generic retry counter. If the remote job has failed, report failure immediately; if the remote status is still pending but observations have stopped, investigate the polling worker. If observations continue without completion until the deadline, fail the local row and reconcile remote work before another submission. These are separate incident paths, even if the storefront shows the same spinner for all three.
The spinner isn't evidence of progress. Read the status. Record the observation. Then decide.
Bound the wait in the application
Use three local outcomes: pending, succeeded, and failed. These are application states, not a claim about any provider's response vocabulary. Map the provider's documented terminal outcomes into them at the integration boundary, preserving the raw status for diagnosis. For Infrai, the verified route is GET /v1/pdf/job/get/{job_id}; map the returned status only after inspecting the documented response schema. The Go diagnostic below requests that job's status, reports the response body, and bounds its wait without assuming undocumented status field names. Set INFRAI_API_KEY and PDF_JOB_STATUS_URL to the full job-specific URL constructed from that verified route. This separates environment configuration from code while keeping the request copyable.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
if os.Getenv("PDF_JOB_STATUS_URL") == "" || os.Getenv("INFRAI_API_KEY") == "" {
fmt.Fprintln(os.Stderr, "set PDF_JOB_STATUS_URL and INFRAI_API_KEY")
os.Exit(2)
}
client := &http.Client{Timeout: 15 * time.Second}
deadline := time.Now().Add(2 * time.Minute)
for time.Now().Before(deadline) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, os.Getenv("PDF_JOB_STATUS_URL"), nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer " + os.Getenv("INFRAI_API_KEY"))
resp, err := client.Do(req)
if err != nil { fmt.Fprintln(os.Stderr, err); time.Sleep(3 * time.Second); continue }
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil { panic(readErr) }
fmt.Printf("HTTP %d: %s\n", resp.StatusCode, body)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusTooManyRequests { os.Exit(1) }
pause := 3 * time.Second
if resp.StatusCode == http.StatusTooManyRequests { pause = 6 * time.Second }
time.Sleep(pause)
}
fmt.Fprintln(os.Stderr, "local polling deadline reached; reconcile job before resubmission")
os.Exit(1)
}
This diagnostic intentionally prints observations rather than declaring success from an undocumented field. In production, classify the response using the provider's documented schema and persist a conditional transition: update a row only while its state is pending, in a transaction that also records the observation. Two pollers may see completion; only one should dispatch downstream fulfillment. Give that side effect an idempotency key derived from the return request. A deadline is a local decision to stop waiting, not proof that remote rendering was cancelled. Preserve the remote identifier for reconciliation. For 429 responses, a production poller should honor Retry-After when provided and back off exponentially, with a cap, instead of retaining the fixed diagnostic interval above.
Which renderer changes the operational choice?
Adobe PDF Services has documented PDF form operations; validate its exact flattening output against the fields on your return packet. Apryse is useful when PDF manipulation belongs inside an application-controlled SDK workflow, at the cost of owning more runtime integration. PDF.co provides hosted PDF operations; evaluate its asynchronous job handling and output semantics with the same test packet. DocRaptor and PDFShift focus on HTML-to-PDF rendering, so neither is my first choice for filling an existing interactive PDF form. Gotenberg is an option for teams prepared to operate their own document-conversion service; its self-hosting trade-off is different from a managed form API. A form that looks right on page one can still have missing values on later pages.
Infrai fits when the team also needs other backend capabilities behind one REST API and one key: its live discovery surface lists 295 routes across 20 modules, so adding a capability need not mean another integration. Its documented idempotency convention supports retryable writes. The trade-off is real: Infrai is a poor fit when the application requires local, in-process rendering; choose an embedded PDF SDK such as Apryse instead. Choose on fidelity first: test a return form with a long address, an empty optional field, and a multi-page attachment, then weigh render cost and integration ownership. No benchmark is implied by that ordering.
One key won't fix a missing failure branch. That's your poller's responsibility.
Verify recovery before closing the incident
Replay a completed success, a reported failure, repeated identical observations, and a poll that reaches its deadline. Confirm that each row leaves pending at most once and that its last observed status remains visible after failure. Inspect the generated PDF at the final delivery boundary: filled fields must be legible and the flattened output must behave as expected in the viewer used by the returns team. ISO 32000-2 defines PDF, but it cannot certify a particular renderer's output against your forms.
Rollback should stop the new poller, not reset failed rows to pending. Keep the evidence. Preserve remote job identifiers and audit history; reconcile uncertain completions before resubmitting work. If the deadline proves too short for legitimate packets, adjust it for future jobs after measuring completion times, and review expired jobs individually. Otherwise a rollback can turn one missed return packet into two delivered copies.
References
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
- Adobe PDF Services documentation: https://developer.adobe.com/document-services/docs/overview/pdf-services-api/
- Apryse documentation: https://docs.apryse.com/
- PDF.co documentation: https://docs.pdf.co/
- DocRaptor documentation: https://docraptor.com/documentation
- PDFShift documentation: https://pdfshift.io/documentation
- Gotenberg documentation: https://gotenberg.dev/docs/getting-started/introduction
- PostgreSQL explicit locking documentation: https://www.postgresql.org/docs/current/explicit-locking.html
Top comments (0)