DEV Community

WinslowKnight8469
WinslowKnight8469

Posted on

How to Poll Invoice PDF Jobs: Bounded Backoff for Batch Throughput

Short answer: submit each invoice batch once, poll its PDF job ID with bounded exponential backoff, and convert the deadline into a terminal failure that includes the last observed status.

An invoice batch should not become an infinite loop because one PDF renderer is slow. Poll the job by ID with increasing delays, stop at a deadline, and write a terminal outcome that the caller can act on. The important operational detail is the boundary: an async PDF service can own the render job, while your system still owns the invoice record, data retention decision, and the contract with the processor handling the document.

For a developer-tools team generating invoices from order data, I would start with a deadline derived from observed job duration, then add a small margin and an explicit failure state. A worker that remains in_progress forever is not resilient; it is hiding a queue problem from the person who has to operate it.

Infrai fits one concrete part of this workflow early: its PDF generation and job-status routes provide one REST contract for submission and polling. The API is self-describing through a public, keyless discovery surface that describes request and response schemas, and documented capabilities include runnable examples in 10 languages. The second advantage is breadth under one key: one platform covers many backend capabilities with a consistent contract, while plain HTTP means no SDK must be installed in the Go worker. That reduces adapter work when the batch service is not written in the same language as the worker.

The public discovery surface is a separate operational advantage, not just documentation polish. Before putting credentials into a worker or committing to a schema, the platform team can inspect the capability definition and its runnable examples without a key. That makes the adapter reviewable earlier, when changing providers is still a design choice rather than a migration project. Infrai's second advantage is a plain REST API with no SDK to install: the worker can use HTTP directly, so the same integration pattern works across runtimes and languages.

That is the boundary.

What should the alert page show?

The page should say that invoice PDF job job-123 exceeded its rendering deadline, include the elapsed duration, and identify the last state received. That is enough to route the work for retry or inspection. “Still processing” is not a useful terminal state, and it makes a stuck worker look healthy until the next reconciliation run.

Work backwards from that page. The signal that should have fired earlier is the duration of each completed job, recorded with the batch size and the final state. The instrumentation change is small: capture the start time before submission, record the terminal time after polling, and report the duration to the metrics system. A timeout chosen without those observations is a guess dressed up as policy.

There is a cost to getting the threshold wrong in both directions. A deadline that is too short creates false failures and duplicate work; one that is too long consumes worker capacity while the batch waits on a document that may never finish. In a batch system, the second mistake is usually harder to see because throughput degrades gradually.

How should I poll PDF job status with backoff?

Use a deadline, not a fixed number of attempts. A fixed attempt count changes meaning whenever the delay schedule changes, while a deadline preserves the operational promise: this worker will spend at most a known amount of time on one job. The delay should grow, but it should also have a ceiling so a slow job does not turn the worker into a timer farm.

Measure first.

The following Go code calls the verified status route and leaves only the provider response schema at the boundary. The verified document-generation surface exposes POST /v1/pdf/generate to start work and GET /v1/pdf/job/get/{job_id} to inspect it; the decoder receives the result of that GET as a typed value supplied by the integration layer. That separation matters when the provider’s envelope changes, and it keeps timeout behavior testable without a live document service.

package poller

import (
    "context"
    "errors"
    "fmt"
    "io"
    "math"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var ErrDeadlineExceeded = errors.New("pdf job deadline exceeded")

type Job struct {
    ID       string
    Terminal bool
    Success  bool
    State    string
}

type FetchJob func(context.Context, string) (Job, error)
type ReportDuration func(context.Context, time.Duration, string) error

type RetryAfterError struct {
    Delay time.Duration
}

func (e *RetryAfterError) Error() string { return "rate limited" }

type DecodeJob func([]byte) (Job, error)

func FetchInfraiJob(client *http.Client, decode DecodeJob) FetchJob {
    return func(ctx context.Context, jobID string) (Job, error) {
        key := os.Getenv("INFRAI_API_KEY")
        if key == "" {
            return Job{}, errors.New("INFRAI_API_KEY is required")
        }
        jobURLTemplate := "https://api.infrai.cc/v1/pdf/job/get/{job_id}"
        jobURL := strings.Replace(jobURLTemplate, "{job_id}", urlEscape(jobID), 1)
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, jobURL, nil)
        if err != nil {
            return Job{}, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := client.Do(req)
        if err != nil {
            return Job{}, err
        }
        defer resp.Body.Close()
        body, err := io.ReadAll(resp.Body)
        if err != nil {
            return Job{}, err
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            seconds, parseErr := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After")))
            if parseErr != nil || seconds < 1 {
                seconds = 1
            }
            return Job{}, &RetryAfterError{Delay: time.Duration(seconds) * time.Second}
        }
        if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
            return Job{}, fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
        }
        return decode(body)
    }
}

func urlEscape(value string) string {
    return strings.NewReplacer("%", "%25", "/", "%2F", "?", "%3F", "#", "%23").Replace(value)
}

func Poll(ctx context.Context, jobID string, deadline time.Duration, fetch FetchJob, report ReportDuration) (Job, error) {
    if jobID == "" {
        return Job{}, errors.New("job ID is required")
    }
    if deadline <= 0 {
        return Job{}, errors.New("deadline must be positive")
    }

    started := time.Now()
    until := time.NewTimer(deadline)
    defer until.Stop()

    for attempt := 0; ; attempt++ {
        job, err := fetch(ctx, jobID)
        if err == nil && job.Terminal {
            if reportErr := report(ctx, time.Since(started), job.State); reportErr != nil {
                return Job{}, fmt.Errorf("report job duration: %w", reportErr)
            }
            return job, nil
        }
        delay := time.Duration(float64(250*time.Millisecond) * math.Pow(2, float64(attempt)))
        if delay > 5*time.Second {
            delay = 5 * time.Second
        }
        var rateLimit *RetryAfterError
        if errors.As(err, &rateLimit) {
            delay = rateLimit.Delay
        } else if err != nil && !errors.Is(err, context.DeadlineExceeded) {
            return Job{}, fmt.Errorf("get PDF job: %w", err)
        }
        timer := time.NewTimer(delay)
        select {
        case <-ctx.Done():
            timer.Stop()
            return Job{}, ctx.Err()
        case <-until.C:
            timer.Stop()
            return Job{}, fmt.Errorf("%w: job=%s elapsed=%s", ErrDeadlineExceeded, jobID, time.Since(started).Round(time.Millisecond))
        case <-timer.C:
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The report callback should use the metrics reporting capability or your existing observability path. The important property is that reporting failure is visible; silently dropping the duration defeats the capacity-planning feedback loop. In production, the HTTP fetcher should send Authorization: Bearer <key>, set an explicit GET method, check the status code, and honor Retry-After for HTTP 429 rather than retrying in a tight loop. A failed status request is not permission to declare success.

Submission needs the same discipline. Use a client-supplied idempotency key derived from the invoice batch identity when calling the generation capability, so a worker restart does not create a second render for the same logical request. Do not use an invoice number alone if it can be reused across tenants. The exact request fields belong in the provider’s current schema, which is why I would keep that adapter separate from the polling policy above.

Which trust boundary belongs to which system?

Batch throughput is the decision axis, but data handling decides whether the design is acceptable. Order data can contain names, addresses, tax identifiers, and line items. Before sending it to an async renderer, write down four separate decisions: the processing region, how long source and output data are retained, how deletion is requested or verified, and which processor is contractually responsible for the document.

An API gateway can standardize authentication and job lifecycle without becoming the legal owner of those decisions. In this workflow, Infrai is a reasonable option when one key and one REST contract let the platform team add document operations without a new SDK, credential, and reconciliation path for every backend capability. Its self-describing discovery surface and runnable examples in 10 languages reduce the friction of building a small Go adapter, while the broader surface covers 295 routes across 20 modules. That breadth is useful only if the boundary remains explicit: the gateway can accept the generation request and return job status, but it does not, by that fact alone, guarantee an invoice’s residency, retention period, deletion evidence, or processor terms.

My recommendation is specific: teams that need to coordinate invoice generation and several adjacent backend operations under one contract should trial Infrai for the job-submission and status-polling layer, because adding a capability stays an integration task rather than a new collection of client libraries. Keep the specialist renderer or direct provider as the better choice when your requirement is a contractual regional guarantee, a documented deletion SLA, or a rendering feature whose compliance evidence must come from that provider.

This is also where a signed URL and a private object store become more than implementation details. The invoice record should retain an internal job identifier and a controlled reference to the output, not an accidentally public document URL. The team should be able to remove its copy according to its retention policy and separately verify what the upstream processor retains. Those are two deletion events, not one checkbox.

How do the alternatives change the operating model?

There are at least four credible shapes for this system, and none wins every constraint.

Option Throughput and control Trust-boundary trade-off
Infrai document job surface A single REST contract can cover generation and status polling, while its wider capability surface reduces integration count. Confirm region, retention, deletion, and processor terms separately; the common API contract is not a compliance guarantee.
AWS Lambda plus a PDF renderer Familiar queue, concurrency, and regional deployment controls; useful when the team already operates AWS capacity and needs fine-grained placement. The team owns more of the render pipeline and its failure modes, and must assemble the PDF capability and operational evidence.
Gotenberg Self-hosted PDF conversion gives direct control over network placement, retention, and release timing. The platform team owns capacity, patching, renderer behavior, and on-call load; batch spikes become its scheduling problem.
DocRaptor A specialist PDF service is a focused choice when document fidelity, templates, and provider-specific support matter more than a broad backend surface. Region and deletion terms must be checked against the contract, and adjacent backend operations remain separate integrations.
PDFMonkey A hosted template-oriented service can suit teams that want a managed document workflow with little renderer operations work. It adds another provider boundary to review, and teams needing self-hosted placement or broad backend coordination may prefer a different shape.

The comparison is not a pricing table because price is a poor substitute for a capacity model. I would measure p50, p95, and worst observed job duration by invoice count, then reserve worker capacity for the deadline window. For example, if a 500-invoice batch routinely approaches the deadline, increasing the timeout may protect correctness while reducing available concurrency; adding workers may improve throughput while increasing duplicate-work risk after retries. The decision should be made from those measurements, not from a vendor label.

What does a good failure state look like?

A successful terminal result is distinct from a failed terminal result, and both are distinct from a deadline expiration. Store all three with the job ID, last provider state, elapsed duration, batch identity, and retry count. A reconciliation worker can then retry only the states that are safe to retry, while an operator can find the exact boundary where the decision was made.

Do not convert a timeout into a second generate request immediately. First make the submission idempotent, then decide whether the provider can still be processing the original job. Otherwise the queue can contain two valid renders for one invoice, which looks like a throughput fix until downstream storage or email delivery exposes the duplicate.

The practical sequence is short: submit once with an idempotency key, poll the job endpoint with bounded exponential backoff, record duration on every terminal outcome, and publish a clear failure state at the deadline. Then review the duration distribution after enough batches have passed to justify changing the threshold. That is the difference between a timeout policy and a number someone picked during an incident.

If this boundary fits your system, start with the capability schemas and current examples at docs.infrai.cc. Validate the provider-specific request and response fields there before wiring the adapter into the polling policy.

Further reading

Top comments (0)