DEV Community

HayesSterling2614
HayesSterling2614

Posted on

Azure Service Bus vs AWS SQS Retry Logic: Failed Messages Explained

For failed messages in Azure Service Bus and AWS SQS, a periodic health-record cleanup should not depend on a web request staying open; choose retry logic from the delivery guarantee the job needs. For a cleanup that can be repeated safely, I would use a durable queue, a bounded visibility or lock interval, explicit attempt state, and a dead-letter path. The important comparison is not which queue has the nicer retry API. It is what happens after a worker disappears between the database commit and the acknowledgement.

That boundary decides the design.

I would model a cleanup item with an idempotency key such as patient_id + cleanup_period, then make the database write conditional on that key. That gives the scheduler permission to redeliver work without turning a transient timeout into duplicate deletion. A healthtech workload also needs an audit record for each attempt, because “the message was retried” is not enough evidence when an operator later asks which records were touched.

What guarantee does the cleanup actually need?

The usual choice is at-least-once delivery with an idempotent handler. Exactly-once effects require coordination with the destination system; a queue acknowledgement alone cannot provide that across a separate database transaction. At-most-once processing avoids duplicates by acknowledging before work, but a worker crash can lose the cleanup, which is usually the wrong trade for retention work.

I start with a small failure table rather than a product feature list:

Failure Required behavior Design response
Worker dies while processing Work becomes visible again Set a visibility or lock period longer than the normal transaction, then extend it only while progress is real
Database succeeds, acknowledgement fails The item may run again Make the write idempotent and record the attempt key
A poison item keeps failing Other work continues Bound attempts, preserve the error, and move the item to a reviewable dead-letter destination
Cleanup exceeds the initial lease No silent overlap Heartbeat or split the job into smaller units; never assume a lease is a transaction

The numbers should come from measurements. If p99 processing time is 42 seconds, a 30-second lease is a capacity and correctness problem, not a tuning preference. I would add margin for database contention, then alert when extensions become common. A long lease reduces duplicate starts but delays recovery after a dead worker; a short lease improves recovery but increases duplicate risk.

How do Azure Service Bus and AWS SQS handle retry logic?

Amazon SQS uses a visibility timeout: receiving a message hides it for a period, and a message becomes available again when that period expires unless the consumer deletes it. The documentation also describes changing the timeout while processing, which is useful for variable-duration cleanup, but the application still owns idempotency and failure classification.

Azure Service Bus uses a lock on a received message. A worker can complete, abandon, defer, or dead-letter the message, and lock renewal matters when processing runs past the initial lock duration. The operational question remains the same: can the handler safely run twice, and what evidence is retained when it cannot succeed?

RabbitMQ uses acknowledgements and redelivery rather than a visibility-timeout model. A consumer can reject or negatively acknowledge work, and deployments commonly combine that behavior with a dead-letter exchange and a retry topology. The extra routing flexibility is useful, but it also means the retry schedule can become infrastructure that needs tests, metrics, and ownership.

Those are real differences, not a ranking. SQS makes invisible-until-timeout behavior central; Service Bus exposes settlement and lock lifecycle; RabbitMQ leaves more of the retry route to the topology. A team comparing them should test the same crash points against each model instead of translating feature names one-for-one.

A minimal worker path in Go

The queue adapter should be boring. Put delivery-specific operations behind an interface, and keep the healthtech policy in the handler and repository. This example records the idempotency key before acknowledging the delivery; the adapter decides how Ack and Retry map to its queue.

One constraint matters more than the syntax.

type Delivery interface {
    Ack(context.Context) error
    Retry(context.Context, time.Duration) error
    DeadLetter(context.Context, error) error
}

type Cleanup struct {
    Key       string
    PatientID string
    Period    string
}

func process(ctx context.Context, d Delivery, job Cleanup, store Store) error {
    claimed, err := store.Claim(ctx, job.Key)
    if err != nil {
        return d.Retry(ctx, 30*time.Second)
    }
    if !claimed {
        return d.Ack(ctx)
    }

    if err := store.ApplyCleanup(ctx, job); err != nil {
        _ = store.RecordFailure(ctx, job.Key, err)
        if IsPermanent(err) {
            return d.DeadLetter(ctx, err)
        }
        return d.Retry(ctx, 2*time.Minute)
    }

    if err := store.RecordSuccess(ctx, job.Key); err != nil {
        return d.Retry(ctx, 30*time.Second)
    }
    return d.Ack(ctx)
}
Enter fullscreen mode Exit fullscreen mode

The Claim operation must be atomic and keyed by the cleanup identity; otherwise two deliveries can pass the check together. The retry delay is deliberately an example policy, not a universal value. In production I would derive it from the worker’s p99 duration, the queue’s lease behavior, the database’s contention budget, and the retention deadline.

What should the on-call team measure?

I want separate time series for receive-to-start latency, processing duration, redelivery count, lease or visibility extensions, dead-letter volume, and acknowledgement failures. A single “queue depth” graph hides the case where depth is low but one poison item is being retried indefinitely. Track age of the oldest pending item as well; a periodic cleanup can look healthy until its deadline has already passed.

The service-level objective should name the effect, not just the queue. For example: 99.9% of eligible cleanup items are completed or placed in an auditable review state before their retention deadline, with no untracked deletion. The exact target belongs to the data-retention policy and risk review. The queue is only one component in that budget.

There is no universal winner. A visibility-timeout model is a poor fit when the team cannot measure processing duration or safely make the destination idempotent; a lock-settlement model is a poor fit when operators cannot own lock renewal and dead-letter review. In either case, the limitation is operational, not cosmetic.

Tests should force a crash before the database commit, after the commit, before acknowledgement, and during lease renewal. Run a duplicate delivery with the same key. Then inspect the audit trail, not merely the final row count. This catches the failure mode that a happy-path integration test misses.

The decision rule

Choose the delivery model whose failure semantics your team can observe and operate. Compare lock or visibility behavior, redelivery controls, dead-letter handling, delay scheduling, client support, and the operational burden of running the retry path. Managed queueing can reduce maintenance work, while a self-hosted broker can provide topology control; neither removes the need for idempotent effects, capacity planning, or an SLO.

For a periodic healthtech cleanup, my default is at-least-once delivery, an idempotency key enforced by the destination database, bounded retries, and an auditable dead-letter workflow. The deciding test is simple: stop a worker at each acknowledgement boundary and prove that the next outcome is either one safe effect or one visible item for review.

Sources

Top comments (0)