Dead-Letter Queue Handling for Failed Lease Extractions

A dead-letter queue (DLQ) is where a lease extraction goes to be remembered rather than discarded. When a document survives its retry budget and still cannot be turned into a valid abstraction, the worst possible outcome is silent loss — a lease that never posted, a rent roll with a hole no one notices until an audit. The precise problem this page resolves is the DLQ lifecycle: what a failed async batch pipeline should route into a dead-letter queue, exactly what context to preserve alongside it, how to triage the accumulating pile, and how to redrive a fixed document back into extraction without ever committing the same rent twice.

This is deliberately not another backoff guide. Deciding whether a failure is transient (retry) or permanent (dead-letter) is the job of error handling & retry logic; that page owns the classifier and the exponential backoff loop. This page begins one step later — at the moment a failure has already been judged permanent, or has exhausted its attempts — and follows it through the envelope, the triage dashboard, the redrive, and finally retention. The DLQ is the pipeline’s institutional memory of everything it could not process, and treating it as a first-class, queryable, replayable store is what separates a recoverable outage from a lost portfolio.

Architectural context

Every stage of Parsing & Extraction Workflows that can fail permanently drains into one place. Upstream, the retry layer classifies each exception and either loops it through backoff or hands it off; when it hands off, it hands off here. A 400 schema rejection, a fundamentally corrupt PDF, or a document that has failed its fourth attempt all arrive at the DLQ carrying a verdict, not a question. Transient blips never reach it — routing a recoverable timeout into the dead-letter queue would bury a self-healing failure among genuine defects and inflate the depth metric operators alert on.

Two things must never be confused with the DLQ. Low-confidence-but-valid documents — a scan the model simply read poorly — are not failures at all; they divert through fallback routing logic to a human review queue, because a reviewer can still read them. And the redrive path leans entirely on the same content-hash identity that the idempotency patterns for S3-event-triggered lease ingestion establish at ingestion — without a stable key, replaying a dead-lettered document risks a duplicate commit. The DLQ answers exactly one question: “this document is permanently stuck; what do we need to fix it and safely try again?”

Dead-letter queue lifecycle for a failed lease extraction A failed extraction task reaches a permanent-versus-transient decision. Transient failures loop back up to retry and backoff, owned by error handling and retry logic, and never enter the dead-letter queue. Permanent failures and attempts-exhausted tasks are routed into the dead-letter queue, which stores a validated envelope carrying the original payload reference, content hash, error class, stack trace, retry count, and first and last seen timestamps. From the queue, operators work a triage dashboard that alerts on queue depth. A fixed decision follows: when the root cause is fixed, the document is redriven idempotently, keyed on its content hash so no double commit occurs, and re-injected into the extraction pipeline; when it is not fixed within the retention window, it expires to cold archive. Retry with backoff error handling & retry logic Failed task extraction raised permanent? or attempts ≥ cap Dead-letter queue validated envelope · original payload ref · content hash · error class · stack trace · retry count · first / last seen Triage dashboard alert on queue depth root cause fixed? Idempotent redrive key = content hash Extraction pipeline re-inject · no double commit Retention TTL expire / archive transient permanent fixed expired
Only permanent and attempts-exhausted failures enter the DLQ; transient ones loop back to retry. From the queue a document is triaged, then either redriven idempotently on its content hash or expired once the retention window closes.

The dead-letter envelope

A DLQ entry is worthless if it only says “failed.” What makes a dead-letter queue drainable is a structured envelope that carries everything an operator or a replay tool needs to act without re-deriving context from logs. The envelope is a contract, and it should be validated on write so the queue can never be poisoned with a malformed record.

Envelope field Type Why it must be preserved
document_id str Human-facing handle for the lease in triage and audit
object_key str Pointer to the original bytes in object storage — never inline the document
content_hash str (SHA-256) Stable identity; the only safe redrive key, so replays converge on one record
error_class str The exception type/error_code — the primary grouping dimension for triage
stack_trace str Where and why it broke, so a fix can be written without reproducing blind
retry_count int How many attempts were spent before giving up — distinguishes a hard fail from an exhausted one
first_seen ISO-8601 When the document first failed — feeds age-based retention and staleness alerts
last_seen ISO-8601 Most recent failure, incremented on a poison re-entry so loops are visible
redrive_count int How many times it has been replayed — a rising count flags a poison message

Two rules keep the envelope honest. The content_hash must be a real content hash, not a UUID or a timestamp — a time-based key would let the same document occupy the DLQ under two identities and defeat idempotent redrive. And the original bytes live behind object_key, never inline: a DLQ stuffed with base64 PDFs exhausts broker memory and turns depth alerting into noise.

Failure class, DLQ routing, and redrive strategy

Not everything that fails belongs in the DLQ, and not everything in the DLQ is redriven the same way. The table below maps the outcome of failure classification (which happens upstream in the retry layer) to whether it dead-letters and how it should be replayed once addressed.

Failure class Enters DLQ? Redrive strategy once fixed
Transient I/O timeout, 429, 5xx No — retried with backoff N/A; recovers automatically before it ever reaches the DLQ
400 / 422 schema rejection Yes Fix the mapping or schema, then redrive the batch of the same error_class
Corrupt / unparseable document Yes Redrive after a preprocessing fix (re-OCR, repair); expire if truly unreadable
Password-protected addendum Yes Hold until a credential is supplied, then redrive that single document
Attempts exhausted on a flaky provider Yes Bulk redrive once the provider recovers — safe because every entry is hash-keyed
Poison message (fails every redrive) Yes (quarantined) Do not auto-redrive; escalate to manual review after N redrive_count
Low-confidence but valid read No — review queue N/A; routes through fallback routing, not the DLQ

The poison-message row is the one most systems get wrong. A document that dead-letters, gets redriven, and dead-letters again in an identical way is a poison message: left unchecked it forms an infinite loop that re-inflates the queue every drain cycle. Detecting it is simple — cap redrive_count — but the response must be to quarantine and escalate, never to keep replaying.

The two moving parts are a to_dlq() router that builds and persists the validated envelope when a failure is judged permanent, and a redrive() that re-injects a fixed document idempotently. Both hinge on the content hash. The envelope model below is deliberately distinct from the transient-classification machinery — it assumes the verdict is already “permanent” and concerns itself only with capture and replay.

import hashlib
from datetime import datetime, timezone
from typing import Optional

from pydantic import BaseModel, Field, field_validator


class DeadLetter(BaseModel):
    """Validated envelope for a permanently-failed lease extraction."""
    document_id: str
    object_key: str                       # pointer to original bytes, never inline
    content_hash: str                     # SHA-256 hex — the redrive identity
    error_class: str                      # exception type / error_code for grouping
    stack_trace: str
    retry_count: int = Field(ge=0)        # attempts spent before dead-lettering
    redrive_count: int = Field(default=0, ge=0)
    first_seen: datetime
    last_seen: datetime

    @field_validator("content_hash")
    @classmethod
    def _is_sha256(cls, v: str) -> str:
        if len(v) != 64 or any(c not in "0123456789abcdef" for c in v):
            raise ValueError("content_hash must be a 64-char SHA-256 hex digest")
        return v


def content_hash(document_bytes: bytes, processing_version: str) -> str:
    """Same bytes + same pipeline version => same key, every time."""
    h = hashlib.sha256()
    h.update(document_bytes)
    h.update(processing_version.encode())
    return h.hexdigest()


def to_dlq(dlq, *, document_id: str, object_key: str, chash: str,
           exc: Exception, retry_count: int) -> DeadLetter:
    """Route a permanent failure into the DLQ, merging with any prior entry.

    Assumes the caller has already classified `exc` as permanent (or
    attempts-exhausted); classification itself lives in the retry layer.
    """
    now = datetime.now(timezone.utc)
    existing = dlq.get(chash)                       # keyed on content hash
    if existing is not None:
        # Same document failing again — bump last_seen, don't duplicate.
        existing.last_seen = now
        existing.retry_count = max(existing.retry_count, retry_count)
        dlq.put(existing)
        return existing

    record = DeadLetter(
        document_id=document_id,
        object_key=object_key,
        content_hash=chash,
        error_class=type(exc).__name__,
        stack_trace="".join(__import__("traceback").format_exception(exc)),
        retry_count=retry_count,
        first_seen=now,
        last_seen=now,
    )
    dlq.put(record)                                 # unique on content_hash
    return record

Redrive is where idempotency earns its keep. Re-injecting a dead-lettered document must not create a second abstraction if the original commit had actually succeeded, and it must refuse to loop forever on a poison message.

POISON_THRESHOLD = 3


def redrive(dlq, pipeline, chash: str) -> dict:
    """Re-inject one dead-lettered document idempotently, keyed on its hash."""
    record = dlq.get(chash)
    if record is None:
        return {"status": "not_found", "content_hash": chash}

    # Poison-message guard: quarantine instead of looping.
    if record.redrive_count >= POISON_THRESHOLD:
        dlq.quarantine(chash)
        return {"status": "quarantined", "content_hash": chash,
                "redrive_count": record.redrive_count}

    # The pipeline commit is itself keyed on content_hash with a unique
    # constraint, so re-enqueueing a document already committed is a no-op
    # rather than a double count of rent.
    record.redrive_count += 1
    dlq.put(record)
    pipeline.enqueue(object_key=record.object_key, content_hash=chash)
    dlq.remove(chash)                               # leaves the DLQ; re-enters only if it fails again
    return {"status": "redriven", "content_hash": chash,
            "redrive_count": record.redrive_count}

The safety property is that redrive() never commits anything itself — it only re-enqueues, and the pipeline’s own hash-keyed upsert is what guarantees no double write. That is why the redrive key and the ingestion idempotency key must be the same content hash; if they diverge, a replay slips past the unique constraint and posts a duplicate. Bulk redrive after a fix is then just this function mapped over every entry sharing an error_class.

Edge cases specific to commercial leases

  • Redrive causing a duplicate commit. A document that timed out after its PMS write but before the acknowledgement looks failed and gets dead-lettered, then redriven. Because the commit is an upsert keyed on content_hash, the replay converges on the existing row instead of double-counting the base rent — the single most important invariant in the whole lifecycle.
  • Unbounded DLQ growth. A systemic upstream break can flood the queue with tens of thousands of entries, and a DLQ with no retention becomes a permanent liability. Apply an age-based TTL against first_seen: entries unresolved past the window move to cold archive (still recoverable, no longer counted in depth), so the live queue stays a working set, not a landfill.
  • Poison-message loops. An entry that fails identically on every replay will re-enter the DLQ forever. The redrive_count cap quarantines it after a few attempts and escalates to a human, breaking the loop instead of amplifying it.
  • Losing the original payload. If the envelope stored only a stack trace and not the object_key, the document could never be redriven — the failure would be diagnosable but not recoverable. Always preserve a pointer to the immutable original bytes; the abstraction is disposable, the source lease is not.
  • A transient error mislabeled permanent. If the upstream classifier wrongly tags a 503 as fatal, a recoverable document lands in the DLQ. Triage catches this: a burst of same-error_class entries that all succeed on a single redrive is the signature of a misclassification, and the fix belongs back in error handling & retry logic, not in the DLQ.

When to escalate

The DLQ is an operational surface, and its depth is a leading indicator. Escalate out of the automated drain path when:

  • Queue depth crosses a threshold — a sustained rise past a defined ceiling means the drain rate has fallen behind the failure rate. Alert operations and open a triage session before the backlog becomes a portfolio-wide gap.
  • A single error_class spikes suddenly — a systemic defect (a changed OCR API contract, a bad deploy) is filling the DLQ faster than any manual redrive can empty it. Circuit-break the upstream stage so it stops feeding the queue, fix the root cause, then bulk-redrive by hash.
  • redrive_count reaches the poison threshold — the document is not fixable by replay; route it to manual review with its full envelope so a person decides its fate.
  • Entries age past the retention window — anything approaching TTL expiry needs a human decision to redrive or let it archive, so no lease is silently aged out of existence.

Frequently asked questions

What belongs in a dead-letter queue versus a retry? Only permanent failures and attempts-exhausted tasks. A transient timeout, a 429, or a 5xx is retried with backoff and never dead-lettered — routing recoverable failures into the DLQ buries self-healing blips among real defects and corrupts the depth metric operators alert on. Classification happens upstream in the retry layer; the DLQ receives only the verdict “permanent.”

How do I redrive without double-committing rent? Key both the redrive and the pipeline’s commit on the same SHA-256 content hash, with a unique constraint on the write. Redrive only re-enqueues; it never commits directly. Because the downstream upsert is hash-keyed, replaying a document whose original commit had actually succeeded converges on the existing row instead of posting a duplicate abstraction.

How do I stop a poison message from looping forever? Track a redrive_count on the envelope and quarantine any entry that reaches a small cap — three is a reasonable default. A document that fails identically on every replay is not fixable by replay, so it must be escalated to manual review rather than re-enqueued indefinitely.

How long should dead-lettered leases be retained? Apply an age-based TTL against first_seen. Keep the live window long enough to cover a realistic fix-and-redrive cycle, then move unresolved entries to cold archive — still recoverable for audit, but no longer counted in the live depth. This keeps the queue a working set rather than an unbounded liability.

← Back to Async Batch Processing

← Back to Async Batch Processing