Payments are experiencing issues due to temporary restrictions in Russia. If your payment does not go through, please submit a support request.Our support team is available 24/7 — we are always here to help with hosting and server issues.We are now accepting requests for dedicated server rental and colocation services in our data center.Reminder: we recommend enabling backups for additional data protection.A new VPS/VDS lineup with NVMe storage and improved performance is now available.Maintenance work on some servers has been completed. All services are operating normally.
Article11 min readViews0

Message Fails Again: Checking the Dead-Letter Queue and Safe Return

On an isolated SQLite model, we check the retry limit, move persistent errors to a dead-letter queue, handle rollback on failure between insert and delete, and perform a single return of the corrected message.

Comments 0

A damaged package is separated from the main conveyor into a transparent quarantine section
In this article

If a single message repeatedly breaks the handler, do not leave it in an infinite retry loop. After a persistent error or exhaustion of the attempt limit, move the message to a separate error queue—the dead-letter queue (DLQ). Return it to the working queue only after fixing the root cause and with controls for re-delivery.

For a store owner, the consequence is quite tangible: one incorrect SKU or an unexpected field can stall handlers, delay valid orders, and turn the error log into noise. The DLQ separates a currently unfixable message from the main flow, but by itself it does not correct the data nor guarantee single processing.

Where the retry ends and the error queue begins

State

Action

Result criterion

Temporary error

Retry with pause and limit

Message processed or limit reached

Permanent error

Isolate immediately

The main queue continues to operate

Retry limit exhausted

Move to DLQ

The message no longer cycles in the workflow

Cause resolved

Return in a controlled manner

No record in DLQ; one record in the working queue

Repeat Return

Do Not Create a Copy

The operation reports that there is nothing left to return

We consider a transient error one that may disappear without changing the message: for example, HTTP 503 or a short network timeout. A permanent error is a data or business rule error that the same request will reproduce with the same result: unknown SKU, invalid quantity, missing required identifier. If the classification is doubtful, a limited retry is needed, not an infinite one.

What we check with a small example

The training model uses only the standard Python library and a temporary SQLite file. It does not connect to the network or work with a real broker. After completion, the temporary file system directory is removed along with the test database. The check was performed on October 28, 2026, on CPython 3.12.14 and SQLite 3.53.1.

  • A transient error twice returns the message for retry; after success, it disappears from the queue and does not enter the DLQ;

  • A permanent error moves the message to the DLQ after the first failure;

  • Three transient failures exhaust the limit and isolate the message;

  • An artificial failure between writing to the DLQ and removing from the working queue rolls back the entire transaction;

  • The corrected message is returned once, and a subsequent redrive—a controlled return—does not create a copy.

The scenario runs with the standard Python 3.12 interpreter. All identifiers, paths, and data are defined within the example; the database is created in an isolated temporary directory. There are no operations on working files or external queues.

#!/usr/bin/env python3 import sqlite3 import tempfile from pathlib import Path MAX_ATTEMPTS = 3 def create_schema(conn): conn.executescript(""" CREATE TABLE queue ( message_id TEXT PRIMARY KEY, payload TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT ); CREATE TABLE dead_letter ( message_id TEXT PRIMARY KEY, payload TEXT NOT NULL, attempts INTEGER NOT NULL, reason TEXT NOT NULL ); """) def enqueue(conn, message_id, payload): with conn: conn.execute( "INSERT INTO queue(message_id, payload) VALUES (?, ?)", (message_id, payload), ) def record_failure(conn, message_id, error, *, permanent, inject_crash=False): with conn: row = conn.execute( "SELECT payload, attempts FROM queue WHERE message_id = ?", (message_id,), ).fetchone() if row is None: raise LookupError(message_id) payload, attempts = row next_attempt = attempts + 1 if permanent or next_attempt >= MAX_ATTEMPTS: reason = "permanent" if permanent else "attempt_limit" conn.execute( """INSERT INTO dead_letter(message_id, payload, attempts, reason) VALUES (?, ?, ?, ?)""", (message_id, payload, next_attempt, reason), ) if inject_crash: raise RuntimeError("simulated crash before source delete") conn.execute("DELETE FROM queue WHERE message_id = ?", (message_id,)) return "dead_letter" conn.execute( """UPDATE queue SET attempts = ?, last_error = ? WHERE message_id = ?""", (next_attempt, error, message_id), ) return "retry" def acknowledge(conn, message_id): with conn: conn.execute("DELETE FROM queue WHERE message_id = ?", (message_id,)) def redrive(conn, message_id, corrected_payload): with conn: row = conn.execute( "SELECT 1 FROM dead_letter WHERE message_id = ?", (message_id,) ).fetchone() if row is None: return False conn.execute( """INSERT INTO queue(message_id, payload, attempts, last_error) VALUES (?, ?, 0, NULL)""", (message_id, corrected_payload), ) conn.execute( "DELETE FROM dead_letter WHERE message_id = ?", (message_id,) ) return True def count(conn, table, message_id): if table not in {"queue", "dead_letter"}: raise ValueError(table) return conn.execute( f"SELECT COUNT(*) FROM {table} WHERE message_id = ?", (message_id,) ).fetchone()[0] with tempfile.TemporaryDirectory(prefix="dlq-lab-") as tmpdir: db_path = Path(tmpdir) / "queue.sqlite3" conn = sqlite3.connect(db_path) create_schema(conn) enqueue(conn, "transient-ok", "sku=GOOD&qty=1") assert record_failure(conn, "transient-ok", "HTTP 503", permanent=False) == "retry" assert record_failure(conn, "transient-ok", "HTTP 503", permanent=False) == "retry" acknowledge(conn, "transient-ok") assert count(conn, "queue", "transient-ok") == 0 assert count(conn, "dead_letter", "transient-ok") == 0 print("transient_then_success: queue=0 dead_letter=0") enqueue(conn, "bad-sku", "sku=UNKNOWN&qty=1") assert record_failure(conn, "bad-sku", "unknown SKU", permanent=True) == "dead_letter" assert conn.execute( "SELECT attempts, reason FROM dead_letter WHERE message_id = ?", ("bad-sku",), ).fetchone() == (1, "permanent") print("permanent_error: queue=0 dead_letter=1 attempts=1 reason=permanent") enqueue(conn, "retry-limit", "sku=GOOD&qty=2") assert record_failure(conn, "retry-limit", "timeout", permanent=False) == "retry" assert record_failure(conn, "retry-limit", "timeout", permanent=False) == "retry" assert record_failure(conn, "retry-limit", "timeout", permanent=False) == "dead_letter" assert conn.execute( "SELECT attempts, reason FROM dead_letter WHERE message_id = ?", ("retry-limit",), ).fetchone() == (3, "attempt_limit") print("attempt_limit: queue=0 dead_letter=1 attempts=3 reason=attempt_limit") enqueue(conn, "rollback", "sku=BROKEN&qty=1") try: record_failure( conn, "rollback", "invalid payload", permanent=True, inject_crash=True, ) except RuntimeError as exc: assert str(exc) == "simulated crash before source delete" else: raise AssertionError("injected failure did not happen") assert count(conn, "queue", "rollback") == 1 assert count(conn, "dead_letter", "rollback") == 0 print("transaction_rollback: queue=1 dead_letter=0") assert redrive(conn, "bad-sku", "sku=FIXED&qty=1") is True assert redrive(conn, "bad-sku", "sku=FIXED&qty=1") is False assert conn.execute( "SELECT payload, attempts FROM queue WHERE message_id = ?", ("bad-sku",), ).fetchone() == ("sku=FIXED&qty=1", 0) acknowledge(conn, "bad-sku") assert count(conn, "queue", "bad-sku") == 0 print("corrected_redrive: first=true second=false final_queue=0 dead_letter=0") conn.close() assert not db_path.exists()

Observed result

transient_then_success: queue=0 dead_letter=0 permanent_error: queue=0 dead_letter=1 attempts=1 reason=permanent attempt_limit: queue=0 dead_letter=1 attempts=3 reason=attempt_limit transaction_rollback: queue=1 dead_letter=0 corrected_redrive: first=true second=false final_queue=0 dead_letter=0

All assert checks completed successfully with exit code 0. After exiting TemporaryDirectory, the database file was absent. This confirms the behavior of the training model specifically; on RabbitMQ, Amazon SQS, or a custom broker, you must separately verify their delivery guarantees, consumer competition, message retention, and access rights.

Why the transfer must be atomic

The operation 'write to DLQ, then remove from the main queue' consists of two changes. If the process terminates between them without a transaction, you may end up with a copy in both queues or lose the message entirely. In the model, both changes occur within a single SQLite transaction context: an exception before removal rolls back the insertion, leaving the original message in place.

This behavior of the connection context manager — committing on success and rolling back on exception — is described in the sqlite3 documentation for Python 3.12. The manager does not automatically close the connection, so the example explicitly calls close.

Return after fix: there is a catch

Redrive must not mean 'return everything and look'. First, fix the root cause: the SKU reference, message schema, permissions, or handler code. Then select a limited batch, reset the attempt counter according to an explicit rule, and monitor error rates, queue length, and processing time.

In the model, the redrive method inserts the corrected message into the working queue and removes the record from the DLQ within a single transaction. The first execution returns true; the second returns false. This protects only against re-running this specific function in a single-process model; a distributed system must still have an idempotent handler.

In Amazon SQS, an error queue is linked to the source queue via a redrive policy: after a specified number of receives, the message moves to the DLQ. The SQS guide on DLQ separately warns about the impact on FIFO queue ordering. AWS recommends starting with a low rate and monitoring the system upon return; standard redrive does not modify or filter messages. This is described in the redrive guide.

In RabbitMQ, messages can move to a dead-letter exchange after reject or nack without requeue, upon TTL expiration, exceeding the queue length, or reaching the delivery limit for a quorum queue. Conditions and constraints are listed in the official RabbitMQ documentation on dead lettering. The mechanism differs from SQS, so limit values and guarantees cannot be transferred between products.

What to include in a production implementation

  1. stable message_id and consumer idempotency: re-delivery must not create a second order or payment;

  2. separation of transient and permanent errors with an explicit error code, not just an exception message;

  3. attempt limit, backoff with jitter, and a total retry budget to prevent multiple layers from multiplying the load;

  4. atomic transfer or a documented broker guarantee; if impossible, reconciliation by message_id and a duplicate-removal procedure;

  5. metrics for queue length and DLQ size, age of the oldest message, error rates, and return counts;

  6. limited redrive after remediation: a small batch, stop criteria, operator logs, and the ability to return the message to quarantine;

  7. retention period, encryption, access to the payload, and masking of personal data;

  8. separate validation of event processing order: DLQ and re-injection can alter the chronological order of related messages.

How DLQ integrates with other safeguards

The transactional outbox handles a different failure point: the event must appear after the order is saved, even if the sender is temporarily unavailable. A practical example is in the analysis of transactional outbox for webhook.

The retry limit does not replace idempotency: a single message may arrive again due to an acknowledgment timeout, consumer restart, or redrive. This is separately verified in the material about repeated webhooks and idempotency.

The acceptance criterion is simple: a toxic message must no longer block the main flow; its cause and attempt count must be visible; a failure during transfer must not lose the original data. Corrected messages are returned in small batches, at a specified rate and with a stop criterion; re-delivery must not create a second business result. If even one item cannot be verified, the DLQ remains a repository of problems rather than a recovery mechanism.

Discussion 0

Share your experience and ask questions. Comments without links appear after editorial review.

No comments yet. Start the discussion.