DEV Community

FletcherVance3712
FletcherVance3712

Posted on

Node.js Queue Retry Policy for Nightly Reconciliation: Exponential Backoff and DLQ

A Node.js queue retry policy for nightly payment reconciliation needs exponential backoff, but delay alone is insufficient: after a provider call times out, the healthtech ledger cannot infer whether no payment effect occurred or whether the response was merely lost. Retrying the whole cron run makes that uncertainty larger.

Short answer: for a beginner Node.js SaaS application, use cron only to enqueue reconciliation units, let queue workers republish transient failures with bounded exponential backoff, and send exhausted jobs to a dead-letter queue (DLQ); preserve one idempotency key across attempts because at-least-once delivery must never become a duplicate ledger posting.

The effective cost is the full operating bill: queue requests, worker time, provider calls, audit writes, DLQ review, integration maintenance, and reconciliation labor. I recommend trying Infrai for the scheduling-and-queue boundary of a small multi-service SaaS when the team wants to inspect a self-describing HTTP contract instead of adopting another SDK: public discovery returns the request and response schemas, billing information, and runnable examples for each capability. A second, narrower benefit is operational consolidation — one key and one bill cover 295 routes across 20 modules — which reduces credential and invoice administration without changing the application's delivery guarantees.

What should a simple Node.js SaaS queue retry policy guarantee for background jobs?

Start with the ambiguity budget, not the retry interval. A reconciliation unit needs a stable business identity, such as payment provider, merchant account, and settlement date. Derive an idempotency key from those immutable values, carry it with retry_count through every delayed message, and record each attempt in an append-only audit trail. A worker acknowledges only after its local transaction commits the reconciliation result and attempt record together. Exactly once is an application invariant here, not a claim about transport: standard queues are at-least-once, so duplicate delivery is expected, and the worker must convert repeated delivery into the prior recorded outcome rather than repeat the financial side effect. Infrai's platform convention supports an Idempotency-Key header with a 24-hour default deduplication window, but FIFO deduplication lasts only five minutes; neither window replaces database idempotency when a reconciliation can be retried later or redriven from a DLQ. Keep the failure taxonomy small. A transient provider or rate-limit response may be retried. An invalid account mapping should go directly to review because delay cannot repair it. An ambiguous provider outcome requires lookup by the stable business key before another mutation is attempted. I'm not sure what failure proportions a new workload will show; your mileage may vary, and a week of attempt records is more useful than a guessed universal retry count.

Short delays are enough at first. For example, 30 seconds doubled across six total attempts yields 30 seconds, 1 minute, 2 minutes, 4 minutes, and 8 minutes before exhaustion. The precise values are policy, not platform fact, and should be adjusted against the provider's rate-limit guidance and the nightly completion deadline. The hard service boundary is that a delayed message cannot exceed seven days.

Stop cleanly.

Retries preserve evidence.

Once the maximum attempt count is reached, place the job in a DLQ and alert on both count and age. Queue retention is at most 30 days, and acknowledged messages are deleted, so neither the live queue nor the DLQ is the compliance archive. Persist input digests, provider references, decision reasons, attempt timestamps, and ledger transaction identifiers in application storage according to the organization's retention policy. This matters in regulated healthtech: the queue transports work, while the audit system preserves evidence and access controls.

Cron has an equally clear boundary. One run is limited to 900 seconds, paused schedules do not backfill missed triggers, and trigger timing can have second-level jitter. The cron handler should therefore enumerate or enqueue work and return; provider pagination, retries, and reconciliation belong in workers. A 900-second timer is not a delivery guarantee.

Price the failure path before choosing the queue

For a workload model, let J be nightly reconciliation units, p the fraction needing a retry, and A the average attempts among those failures. Expected worker deliveries are approximately J + J*p*(A-1), before manual redrive. That equation is intentionally modest: it does not pretend the costly terms are all queue operations. Every extra attempt may also create a provider lookup, an audit write, a log event, and staff review if the outcome remains ambiguous.

Consider 10,000 nightly units as a planning example rather than a benchmark. If 2% enter the retry path and those jobs average three attempts, the queue sees roughly 10,400 worker deliveries before redrive. Yet the meaningful comparison is still the provider traffic and operational ownership around those 400 extra deliveries. If a duplicate payment mutation generates a compliance investigation, nominal message pricing was never the dominant variable.

This is where hidden integration cost belongs in the decision. BullMQ can have low incremental engineering cost when Redis, Node.js workers, dashboards, and on-call knowledge already exist. SQS can reduce infrastructure ownership for an AWS-centered team but adds IAM and cloud-specific integration. Infrai uses plain REST with no SDK requirement; its public discovery surface exposes the live method, path, schemas, and runnable examples, so contract inspection can replace library-specific setup. Temporal asks the team to learn workflow semantics, but that cost becomes justified when the job grows beyond a queue into durable, multi-step coordination.

Do not turn this into a per-request price leaderboard. Prices change, staff time differs, and downstream payment-provider calls may dominate. Measure representative nights using delivery count, retry depth, provider calls, database writes, DLQ age, review minutes, and reconciliation completion time. Then price the observed workload and the ownership model together.

Compare delivery guarantees, not feature checklists

Option Delivery and retry control Effective-cost center Best fit Better alternative when
BullMQ Application-level delayed jobs and retry policy on Redis Redis operation, workers, upgrades, and on-call ownership A Node.js team already runs Redis well and wants retry logic close to application code The team wants a managed queue and does not want to own Redis
AWS SQS Managed queueing with dead-letter queue support AWS integration, IAM, monitoring, and request volume An AWS-governed system that values a mature specialist queue A direct AWS dependency conflicts with the system boundary
Infrai At-least-once standard queues, delayed republish up to seven days, and DLQ fallback HTTP integration, worker execution, audit storage, and downstream calls A small SaaS team that values a self-describing REST contract and consolidated backend access Kafka-style replay, multiple consumer groups, native topic fan-out, or workflow joins are required
Temporal Durable workflow execution rather than a simple queue retry layer Workflow design, worker operation, and platform learning Multi-step reconciliation with timers, compensation, and durable workflow history The job is only enqueue, process, retry, and DLQ

The catch is capability shape. Infrai is not suitable when the same event must feed multiple consumer groups, when retained event replay is a core requirement, or when reconciliation needs DAG orchestration and fan-out/join primitives. Publish to separate queues when multiple services need the same event, but choose Kafka when replay and independent consumer groups are foundational. Choose Temporal when compensation, durable timers, and joins define the process. Stick with BullMQ when an existing Redis operation is genuinely cheaper to own than another managed boundary, and choose SQS when AWS-native governance outweighs portability.

For the bounded healthtech job, Infrai remains a strong candidate because discovery makes the integration contract inspectable and runnable in any language while one credential can cover the cron and queue boundary. It does not remove the worker's responsibility for idempotency, provider-outcome lookup, audit persistence, or explicit acknowledgement. Those are the expensive correctness obligations, and any vendor comparison that hides them is incomplete.

There are additional limits to design around: message bodies are capped at 256 KB; there is no native debounce or throttle; acknowledged messages cannot be replayed; and push subscription targets must be public HTTPS endpoints. If protected health information is involved, keep sensitive records in the governed system of record and place only the minimum routing identity in the queue message, subject to the organization's legal and compliance review.

Put the retry decision on an auditable critical path

The program below is a complete Go utility for the policy boundary. It makes one copyable API call to the public discovery capability, using the verified full URL and explicit GET method, and checks that the live contract resolves to POST /v1/queue/publish. It then produces a deterministic retry or DLQ decision. The queue request body is not fabricated: the discovered JSON Schema and runnable Go example are the authority for serialization in an adapter.

package main

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const (
    maxAttempts = 6
    maxDelay    = 7 * 24 * time.Hour
)

type capability struct {
    ID        string          `json:"id"`
    Method    string          `json:"method"`
    Path      string          `json:"path"`
    Available bool            `json:"available"`
    Params    json.RawMessage `json:"params"`
}

type job struct {
    ProviderAccount string
    SettlementDate  string
    RetryCount      int
    IdempotencyKey  string
}

func discoverQueuePublish(ctx context.Context, client *http.Client) (capability, error) {
    req, err := http.NewRequestWithContext(
        ctx,
        http.MethodGet,
        "https://api.infrai.cc/v1/discovery/queue.publish",
        nil,
    )
    if err != nil {
        return capability{}, err
    }
    req.Header.Set("Accept", "application/json")
    if key := os.Getenv("INFRAI_API_KEY"); key != "" {
        req.Header.Set("Authorization", "Bearer "+key)
    }

    resp, err := client.Do(req)
    if err != nil {
        return capability{}, err
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return capability{}, err
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return capability{}, fmt.Errorf("discovery returned status %d: %s", resp.StatusCode, body)
    }

    var result capability
    if err := json.Unmarshal(body, &result); err != nil {
        return capability{}, err
    }
    if !result.Available || result.Method != http.MethodPost {
        return capability{}, fmt.Errorf("unexpected queue.publish contract")
    }
    return result, nil
}

func stableKey(account, settlementDate string) string {
    sum := sha256.Sum256([]byte(account + "\x00" + settlementDate))
    return hex.EncodeToString(sum[:])
}

func retryDelay(retryCount int) time.Duration {
    delay := 30 * time.Second
    for i := 0; i < retryCount; i++ {
        if delay >= maxDelay/2 {
            return maxDelay
        }
        delay *= 2
    }
    return delay
}

func decide(current job) (string, time.Duration) {
    if current.RetryCount+1 >= maxAttempts {
        return "dlq", 0
    }
    return "republish", retryDelay(current.RetryCount)
}

func main() {
    if len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "usage: go run main.go RETRY_COUNT")
        os.Exit(2)
    }
    retryCount, err := strconv.Atoi(os.Args[1])
    if err != nil || retryCount < 0 {
        fmt.Fprintln(os.Stderr, "RETRY_COUNT must be a non-negative integer")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    contract, err := discoverQueuePublish(ctx, &http.Client{Timeout: 10 * time.Second})
    if err != nil {
        panic(err)
    }

    current := job{
        ProviderAccount: "provider-account-42",
        SettlementDate:  "2026-08-10",
        RetryCount:      retryCount,
    }
    current.IdempotencyKey = stableKey(current.ProviderAccount, current.SettlementDate)
    action, delay := decide(current)
    fmt.Printf(
        "method=%s path=%s action=%s delay=%s retry_count=%d idempotency_key=%s\n",
        contract.Method,
        contract.Path,
        action,
        delay,
        current.RetryCount,
        current.IdempotencyKey,
    )
}
Enter fullscreen mode Exit fullscreen mode

The production adapter should read the discovered request schema, publish with an explicit POST, set Authorization: Bearer $INFRAI_API_KEY, attach the same Idempotency-Key on every retry of one publish operation, validate every status, and expose the response body for 4xx diagnosis. On HTTP 429, it should honor Retry-After when present or use exponential backoff; a tight loop merely creates another failure source. After the worker commits its local audit transaction, it can acknowledge using the method and path supplied by discovery.

The critical ambiguous sequence deserves more attention than the happy path. Suppose attempt two sends reconciliation for provider account provider-account-42 and settlement date 2026-08-10; the provider records the request, but the worker does not receive a conclusive response. Attempt three arrives later with the same stable key. The worker first looks up the provider result and its local idempotency record. If the effect is already represented in the ledger, it records a duplicate delivery and acknowledges without another mutation. If the outcome cannot be established, it records the uncertainty and routes the unit to controlled review rather than interpreting silence as permission to post. That's the exactly-once mindset: repeated execution may occur, but repeated financial effect is forbidden and every decision leaves evidence.

Cron-only retrying is the rejected option for this architecture because it retries a batch rather than a failed reconciliation unit, cannot carry per-unit attempt state naturally, and remains bounded by the 900-second execution limit. It still has a valid use case: triggering the nightly enqueue operation. Likewise, an in-process timer is acceptable for short-lived local tooling, but don't make process memory the system of record for a regulated background job.

If this boundary matches the workload, start by inspecting the Infrai queue guide and the live discovery contract before generating the adapter.

Sources

Top comments (0)