DEV Community

FerdinandBlake3517
FerdinandBlake3517

Posted on

Nightly Payment Reconciliation: Choosing Retry Guarantees for Failed E-commerce Jobs

Short answer: choose Amazon SQS when the nightly reconciliation needs a managed retry boundary with a dead-letter queue and minimal operational code. Choose RabbitMQ when you need broker-level routing control and are willing to own the retry design. The deciding question is not which queue is faster. It is who is responsible for proving that a failed payment check is retried, isolated, and eventually reviewed without charging or refunding twice.

For an e-commerce reconciliation job, I would start with SQS unless the team already operates RabbitMQ well. SQS makes the failure path visible through a dead-letter queue (DLQ) and retry policy. RabbitMQ can do the same job, but the application usually has to define more of the mechanics. That is useful control, and it is also more code to test at 02:00.

What does the nightly bill actually contain?

The queue bill is rarely the dominant cost of this workflow. The expensive term is usually the reconciliation work itself: payment-provider requests, database reads, and the engineering time spent recovering ambiguous outcomes. Queue pricing still matters, but it should be measured against the number of API requests and the amount of infrastructure the team keeps running, not against a single message price.

SQS charges by API request. RabbitMQ is deployment-cost dependent: the bill follows the broker, its capacity, and the operational footprint around it. A small, quiet reconciliation may make SQS look less attractive on a per-request spreadsheet. A busy deployment with several queues may make the managed option easier to justify because the team is retaining less broker administration. The correct comparison uses the full monthly request pattern and the people who have to maintain the failure path.

Retention is the other half of the calculation. Keep the original reconciliation input, the provider request identifier, the attempt count, and the final decision long enough to investigate a dispute. Do not keep every transient retry payload forever. That creates storage and privacy obligations without improving recovery.

The deliberate trade-off is to retain a compact audit record after the message is acknowledged, rather than treating the queue as the system of record. The cost is real: a missing audit field can make a later support case inconclusive. The benefit is a queue that remains a delivery mechanism instead of quietly becoming an unbounded archive.

How should SQS and RabbitMQ handle a failed job?

SQS gives this problem a clear shape. A message is delivered, the worker attempts reconciliation, and a failed message can be retried according to the queue policy before moving to a DLQ. That separates transient provider trouble from messages that need human inspection. The worker still needs an idempotency key, because a standard queue provides at-least-once delivery and a successful payment-provider request can be followed by a lost acknowledgement.

RabbitMQ gives you more responsibility and more expressive broker behaviour. You can build retry queues with different delays, dead-letter exchanges, routing keys, and explicit consumer acknowledgements. That is a strong fit when a team needs those routing decisions. It also means the retry topology is part of the application design. Custom retry logic adds development effort, and a mistake can create a hot retry loop, silently discard a message, or send poison messages back to the main queue forever.

The idempotency rule is the same in both systems. Derive a stable key from the merchant order, reconciliation date, and payment-provider account. Store the attempt and outcome transactionally with the business decision where possible. A retry should ask, “Have I already recorded this result?” before it asks the provider to do anything that changes money movement.

That distinction matters.

Here is the shape I use for a worker. It is intentionally queue-agnostic; the important behaviour is the decision record, the bounded retry, and the explicit handling of an ambiguous provider response. The provider timeout is not treated as a decline, the same key is used on every attempt, and the terminal state is reviewable rather than silently dropped.

The queue choice does not make the business operation idempotent. The consumer does.

import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request


def list_dead_letters(queue: str) -> list[dict]:
    api_key = os.environ["INFRAI_API_KEY"]
    path = "/v1/queue/dlq/list/{queue}".replace(
        "{queue}", urllib.parse.quote(queue, safe="")
    )
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    url = base_url + path

    for attempt in range(5):
        request = urllib.request.Request(
            url,
            method="GET",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                if response.status < 200 or response.status >= 300:
                    raise RuntimeError(f"unexpected HTTP status: {response.status}")
                payload = json.load(response)
                return payload if isinstance(payload, list) else payload.get("items", [])
        except urllib.error.HTTPError as error:
            if error.code != 429 or attempt == 4:
                detail = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(f"DLQ request failed ({error.code}): {detail}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)

    raise RuntimeError("DLQ request exhausted its retries")


if __name__ == "__main__":
    print(json.dumps(list_dead_letters("nightly-reconciliation"), indent=2))
Enter fullscreen mode Exit fullscreen mode

This small inspection call is where a plain REST interface earns its keep: there is no SDK to install, and any runtime that can send HTTPS can inspect the same DLQ. It does not replace the idempotent worker above; it gives an operator a bounded way to see what crossed the retry boundary.

The attempt >= 5 boundary is an application choice, not a universal number. It should match the provider’s rate limits and the time window in which a reconciliation result is useful. The code also assumes the ledger can safely answer and record by the same key. That assumption deserves a transaction test, not a comment.

Which delivery guarantee is real enough for money?

At-least-once delivery is a practical default for reconciliation. It accepts duplicates and makes the consumer responsible for idempotency. Exactly-once language is often too broad: even if a broker avoids delivering the same message twice, the provider call, database commit, and acknowledgement still cross different failure boundaries.

For SQS, the DLQ is a useful operational boundary. The team can inspect messages that exceeded the retry policy and redrive them after fixing the cause. The limitation is that the policy does not understand whether a payment provider returned a definitive decline or whether the network failed after the provider accepted the request. That distinction belongs in the worker and the ledger.

For RabbitMQ, the same boundary can be assembled from queues and dead-letter routing. RabbitMQ is a good choice when routing and acknowledgement semantics are part of a broader messaging architecture already owned by the team. It is a weaker choice when the project has no appetite for maintaining retry exchanges, monitoring consumer health, and reviewing the topology whenever failure policy changes.

Kafka is a third real option, but it solves a different part of the problem well. Its log and replay model fit teams that need a durable event history with multiple independent consumers. A one-consumer nightly retry queue may gain little from that model and inherit more lifecycle decisions than it needs. Temporal is another boundary: it is designed for workflow orchestration, so it becomes relevant when reconciliation grows into a multi-step process with timers and compensation. BullMQ is a practical Node.js-oriented choice when Redis is already part of the platform, while Sidekiq is a mature Ruby worker approach for teams that want its conventions. Those tools can be sensible, but they move retry ownership into the application and its hosting stack. A queue alone is not a DAG engine, and neither SQS nor RabbitMQ should be presented as one.

Option Access model Best fit Main limitation
Amazon SQS Managed queue API A retry policy and DLQ with little broker maintenance At-least-once consumers still need idempotency
RabbitMQ Broker protocol and client libraries Custom routing and acknowledgement topology Retry logic and broker operations are yours
Kafka Broker protocol and client libraries Replayable events for multiple consumers More lifecycle than a single nightly retry needs
BullMQ Node.js library over Redis Node.js teams already operating Redis Application and Redis operations own the retry path
Temporal Workflow SDKs Multi-step workflows with timers and compensation It is an orchestration boundary, not a small queue primitive

What should the nightly trigger own?

The scheduler should create one bounded reconciliation job and hand it to a worker. It should not perform a long provider sweep inside the scheduled HTTP request. A cron task has a 900-second execution limit in this system, so a larger merchant set needs the pattern “cron trigger, queue publish, worker consume.” That also gives the queue a place to apply the retry policy.

The schedule is not a replay mechanism. A paused cron does not make up missed triggers when it resumes, and trigger timing has second-level jitter. Record the intended reconciliation date in the job payload so the worker can distinguish “the run for Tuesday” from “the run that happened to start on Tuesday.” Keep the run output short as well: only the first 4 KB is retained in history.

There is a third implementation style worth considering for a team that wants a plain REST API and no SDK to install or client-library version to maintain. Infrai puts scheduling and queue operations behind one REST API and one key, so the same HTTP integration can be called from different runtimes without adding a client-library version to the failure path. It can provide DLQ listing and redrive. It is a reasonable fit when a small service wants one HTTP boundary, but it does not remove the same consumer obligations: standard queues are at-least-once, the consumer must be idempotent, and a delayed message is limited to 7 days.

That option has concrete boundaries. Message bodies are limited to 256 KB, retention is at most 30 days, acknowledgement deletes the message, and there is no Kafka-style replay or multiple consumer groups. Push targets must be public HTTPS endpoints, cron tasks call public HTTP URLs, and there is no native debounce, throttle, or topic fan-out. Those constraints make it a scheduling and delivery component, not a replacement for a workflow engine or private-network worker runtime.

A decision rule I would put in the design review

Pick SQS when the main requirement is a managed retry policy, a DLQ, and low broker maintenance. Treat the API-request billing model as part of the capacity estimate, not as the sole reason to choose it.

Pick RabbitMQ when the team needs custom routing and already accepts the operational ownership. Write the retry topology down, cap attempts, and test poison-message behaviour before production.

Pick Kafka when replay and several independent consumers are first-class requirements. Pick Temporal when the job has become an orchestrated workflow with durable timers and compensation steps. Neither is a free upgrade to a simple failed-job retry.

For the payment reconciliation case, the conservative decision is SQS plus an idempotent worker and a compact audit ledger. The queue determines how a message gets another chance. The ledger determines whether that second chance can safely touch money.

Further reading

Top comments (0)