Dead-Letter Queues and Error Recovery in Clinical ETL

Every clinical ingestion pipeline eventually meets a record it cannot process, and the way it handles that record is a data-integrity decision, not an operational footnote. This page is part of Automated EDC Ingestion & Sync Pipelines, and it focuses on a single discipline: how to catch a failed Electronic Data Capture (EDC) record, decide why it failed, and hold it in a durable, auditable place until it can be corrected — never dropping it on the floor. For clinical data managers, biotech and pharma Python ETL engineers, and regulatory reviewers, a silently discarded record is one of the most serious defects an inspector can find, because it breaks the ALCOA+ Complete principle at the source: the analysis dataset no longer reflects everything the EDC captured, and nobody can prove what went missing. The patterns below split failures into two escrow paths — a quarantine for records that failed schema or validation and need clinical review, and a dead-letter queue (DLQ) for records whose infrastructure retries were exhausted — so that every un-ingested record remains attributable, reconstructable, and recoverable under 21 CFR Part 11 and ICH E6(R3).

A Record’s Error Lifecycle at a Glance

A record enters, is validated and fetched, and takes exactly one of three routes: a clean load, a bounded transient-retry loop that dead-letters on exhaustion, or a schema-conflict path that quarantines for clinical review before it is corrected and re-queued. The teal transitions are the ones that write an audit entry.

Error lifecycle of a clinical ETL record: retry, dead-letter, quarantine, and re-queue A received record flows into a validate-and-fetch decision with three outcomes. The valid path commits and loads the record. A transient transport failure enters a bounded retry loop that re-attempts the validate step; when attempts are exhausted the record is written to a dead-letter queue, which raises an alert to on-call triage and can later be replayed back to the start. A schema-conflict outcome routes the record to a quarantine, which hands off to clinical data manager review; once the schema is fixed the record is re-queued back to the start as a corrected, audited record. The transitions into review and back to the start, and the commit, write audit entries. replay re-queued · audit re-attempt transient schema conflict valid max attempts alert audit Received raw payload + run_id Validate / fetch Commit + load to warehouse Transient retry bounded backoff Dead-letter queue exhausted retries On-call triage classify + replay Quarantine validation failures CDM review correct + sign off Re-queue corrected record

Concepts and Prerequisites

The single most important idea on this page is that “the pipeline failed” is never a sufficient description of what happened — you must record why, because the reason dictates the recovery path and the regulatory obligation. Two failure families call for two entirely different escrow stores.

A quarantine holds records that reached your service intact but failed a content rule: a required SDTM variable is absent, a coded term is not in the controlled terminology, a cross-form check contradicts an earlier visit, or the ODM payload does not conform to the expected schema. These are not infrastructure problems and retrying them a hundred times will never fix them; they need a human — usually a clinical data manager (CDM) — to decide whether the source is wrong, the mapping is wrong, or the schema needs to change. The quarantine is therefore adjacent to discrepancy management: the same records frequently drive Automated Clinical Query Generation.

A dead-letter queue holds records whose processing failed for infrastructure reasons and whose bounded retries were exhausted: an EDC gateway stayed down past the retry cap, a downstream warehouse rejected a write, a message could not be deserialized at all. The record itself may be perfectly valid; the environment simply refused to let it through. Recovery here is a replay after the infrastructure recovers, described in detail in the companion guide on Replaying Dead-Letter Queue Records in Clinical ETL. Collapsing these two stores into one bucket is the most common design mistake in the field, because it either sends validation problems to an on-call engineer who cannot fix them or buries genuine infrastructure outages in a review queue no engineer watches.

Both stores build on the retry primitives documented in Building Retry Logic for EDC API Timeouts: a record only reaches the DLQ after the transient-retry policy has given up, so the classification below assumes that layer already sits upstream. Records that survive both escrow paths flow on into the transformation stage covered in Pandas DataFrames for Clinical Data Cleaning.

The reference implementation assumes a pinned, version-controlled dependency set so the error-handling behavior is reproducible across IQ/OQ/PQ environments:

Dependency Pinned version Role in error recovery
python 3.11.x Runtime; structured exception handling and enum taxonomy
pydantic 2.7.x Schema validation that separates quarantine from clean load
sqlalchemy 2.0.30 Typed DDL and inserts into the quarantine / DLQ tables
psycopg 3.1.19 Postgres driver for the durable escrow store
structlog 24.1.0 JSON audit-log emission on every routing decision
pyyaml 6.0.1 Parsing the version-controlled routing policy

One environment assumption is non-negotiable: the escrow store is durable and independent of the worker. If the DLQ lives only in a worker’s memory or on its local disk, a pod restart destroys the very records you were obligated to preserve. Use a transactional database or a broker with a native dead-letter topic so that writing the failure and acknowledging the source message happen atomically.

Classifying Transient Versus Permanent Failures

Recovery begins with a deterministic classifier that maps every failure to exactly one error_class. The classification is the audit-traceable reason a record was routed the way it was, so it must be a pure function of the failure — never a guess, and never “route everything to the DLQ and sort it out later.”

The classification maps cleanly onto the HTTP and processing taxonomy the ingestion tier already speaks:

Failure signal Class Escrow store Recovery
502 / 503 / 504, read timeout, connection reset TRANSIENT_INFRA Retry loop → DLQ on exhaustion Automatic replay after recovery
Warehouse write rejected (deadlock, lock timeout) TRANSIENT_INFRA Retry loop → DLQ on exhaustion Automatic replay after recovery
Undeserializable / truncated payload (poison message) POISON DLQ immediately (no retry) Engineer inspection, capped replay
400 / 422 schema or ODM conformance failure SCHEMA_INVALID Quarantine CDM review, re-queue after fix
Controlled-terminology / range / cross-form failure VALIDATION_INVALID Quarantine CDM review, query generation
401 / 403 credential failure AUTH Hard stop (whole run) Operator intervention
# 21 CFR Part 11 relevance: the error_class is the deterministic, logged reason a
# record was routed — an inspector re-derives the routing decision from this alone.
from enum import Enum


class ErrorClass(str, Enum):
    TRANSIENT_INFRA = "transient_infra"    # retry, then DLQ if exhausted
    POISON = "poison"                      # cannot even be parsed -> DLQ now
    SCHEMA_INVALID = "schema_invalid"      # ODM/SDTM shape wrong -> quarantine
    VALIDATION_INVALID = "validation_invalid"  # content rule failed -> quarantine
    AUTH = "auth"                          # credential failure -> hard stop


class Route(str, Enum):
    RETRY = "retry"
    DEAD_LETTER = "dead_letter"
    QUARANTINE = "quarantine"
    HARD_STOP = "hard_stop"


# Only these classes are ever eligible for an automatic transient retry.
_TRANSIENT = frozenset({ErrorClass.TRANSIENT_INFRA})
_QUARANTINE = frozenset({ErrorClass.SCHEMA_INVALID, ErrorClass.VALIDATION_INVALID})


def route_for(error_class: ErrorClass, attempts_remaining: int) -> Route:
    """Deterministic routing: reason + retry budget in, single route out."""
    if error_class is ErrorClass.AUTH:
        return Route.HARD_STOP                 # never dead-letter a bad credential
    if error_class in _QUARANTINE:
        return Route.QUARANTINE                # a human, not a retry, fixes these
    if error_class is ErrorClass.POISON:
        return Route.DEAD_LETTER               # unparseable: no point retrying
    if error_class in _TRANSIENT and attempts_remaining > 0:
        return Route.RETRY
    return Route.DEAD_LETTER                    # transient budget exhausted

Two properties make this defensible. A SCHEMA_INVALID record is never retried, because a hundred re-reads of a payload with a missing USUBJID produce a hundred identical failures and a hundred wasted quota units. And an AUTH failure is never dead-lettered, because a revoked credential invalidates the whole run — dead-lettering it would let thousands of otherwise-good records pour into escrow while the real fix is a five-minute secret rotation.

The distinction the classifier encodes is ultimately a question of who can fix the failure and when. A transient infrastructure failure resolves itself: the gateway comes back, the deadlock clears, and an automated replay drives the record through unchanged. Nobody needs to look at the data. A schema or validation failure, by contrast, is a statement about the data itself — the payload as it stands cannot become correct without a human decision, whether that is a corrected source value, an amended mapping, or a controlled schema change. Routing on that axis means the on-call engineer’s DLQ contains only work an engineer can act on, and the CDM’s quarantine contains only work a data manager can act on. When the two are mixed, both queues become untriageable: engineers page on validation problems they cannot resolve, and genuine outages disappear into a review backlog. The error_class is what keeps the two audiences, and their two very different service-level expectations, cleanly separated.

Routing to Quarantine and the Dead-Letter Queue

Once a record is classified, the routing step writes it to the correct durable store with enough context that recovery never has to reconstruct anything from logs. The DLQ record schema is the heart of this — get the fields wrong and a record that failed at 02:00 can never be safely replayed at 09:00.

The essential fields, and why each one is load-bearing:

Field Purpose
raw_payload The exact original bytes, stored verbatim — the only Original copy of the record
payload_sha256 Integrity anchor proving the stored bytes match what arrived
error_class The routing reason; drives replay selection and on-call triage
error_detail Human-readable exception text and vendor correlation id
attempt_count How many times processing was tried before dead-lettering
first_seen / last_seen When the failure first and most recently occurred (contemporaneity)
source_offset The broker offset or delta cursor, so replay resumes at the right point
run_id The pipeline execution that produced the failure (attribution)
status open / replaying / resolved — the record’s own lifecycle
# ALCOA+ requirement: Complete + Original — no failed record is dropped, and the
# exact source bytes are preserved with an integrity hash before any transform.
import hashlib
from datetime import datetime, timezone

import sqlalchemy as sa
import structlog

log = structlog.get_logger("clinical_etl.error_recovery")
metadata = sa.MetaData()


def _utcnow() -> datetime:
    return datetime.now(timezone.utc)


def dead_letter(engine: sa.Engine, table: sa.Table, *, raw: bytes,
                error_class: ErrorClass, detail: str, attempt: int,
                source_offset: str, run_id: str) -> None:
    """Write (or coalesce) a failed record into the durable DLQ, idempotently."""
    digest = hashlib.sha256(raw).hexdigest()      # bind stored bytes to a hash
    now = _utcnow()
    stmt = sa.dialects.postgresql.insert(table).values(
        payload_sha256=digest,
        raw_payload=raw,                          # verbatim ORIGINAL bytes
        error_class=error_class.value,
        error_detail=detail[:2000],               # bounded, PII-safe
        attempt_count=attempt,
        first_seen=now,
        last_seen=now,
        source_offset=source_offset,
        run_id=run_id,
        status="open",
    )
    # Same logical record failing twice must NOT create two rows: coalesce on the
    # payload hash, bump the attempt count, and move last_seen forward.
    stmt = stmt.on_conflict_do_update(
        index_elements=["payload_sha256"],
        set_={
            "attempt_count": table.c.attempt_count + 1,
            "last_seen": now,
            "error_detail": detail[:2000],
        },
    )
    with engine.begin() as conn:                  # atomic with source ack upstream
        conn.execute(stmt)
    log.warning("dead_lettered", payload_sha256=digest, error_class=error_class.value,
                attempt=attempt, run_id=run_id, source_offset=source_offset)

Poison messages — payloads that cannot be parsed at all — deserve special handling. They can never succeed on replay, so they must be capped to prevent a single corrupt record from being redelivered forever. A broker that re-queues an unacknowledged message will otherwise spin the same poison record through your worker endlessly, a failure mode that looks like a healthy-but-busy pipeline while zero real progress is made.

# ALCOA+ requirement: Available — a poison message is captured once and capped,
# so one corrupt payload cannot starve the pipeline via infinite redelivery.
MAX_POISON_REDELIVERIES = 3


def handle_poison(engine, table, msg, run_id: str) -> Route:
    """Cap redelivery of unparseable payloads; park them for engineer inspection."""
    if msg.delivery_count >= MAX_POISON_REDELIVERIES:
        # Preserve the raw bytes even though we cannot interpret them.
        dead_letter(engine, table, raw=msg.body, error_class=ErrorClass.POISON,
                    detail=f"undeserializable after {msg.delivery_count} deliveries",
                    attempt=msg.delivery_count, source_offset=str(msg.offset),
                    run_id=run_id)
        msg.ack()                                 # stop redelivery; it is now durable
        log.error("poison_capped", offset=msg.offset, deliveries=msg.delivery_count)
        return Route.DEAD_LETTER
    msg.nack(requeue=True)                         # give the broker a bounded retry
    return Route.RETRY

The ack() on a capped poison message is the counter-intuitive but correct move: once the raw bytes are safely persisted in the DLQ, acknowledging the source stops the redelivery loop without losing the record. The record is not gone — it has moved from a volatile broker to a durable, auditable store where an engineer can inspect it on their own schedule.

Configuration and Parameterization

Routing thresholds — retry caps, poison caps, alert severities, replay ceilings — are policy, not code. They differ per vendor and per environment and every change to them is a change-controlled event, so externalize them into a validated configuration file and put the escrow tables under the same schema-migration discipline as the rest of the warehouse.

-- DDL is a controlled artifact: this table's migration hash is recorded in the
-- validation package. The DLQ and quarantine are separate tables by design.
CREATE TABLE etl_dead_letter (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload_sha256  CHAR(64)     NOT NULL UNIQUE,   -- integrity + dedup anchor
    raw_payload     BYTEA        NOT NULL,          -- verbatim ORIGINAL bytes
    error_class     TEXT         NOT NULL,
    error_detail    TEXT,
    attempt_count   INTEGER      NOT NULL DEFAULT 1,
    replay_count    INTEGER      NOT NULL DEFAULT 0, -- capped to stop poison loops
    first_seen      TIMESTAMPTZ  NOT NULL,
    last_seen       TIMESTAMPTZ  NOT NULL,
    source_offset   TEXT         NOT NULL,
    run_id          TEXT         NOT NULL,
    status          TEXT         NOT NULL DEFAULT 'open'
                     CHECK (status IN ('open','replaying','resolved'))
);
CREATE INDEX ix_dlq_open ON etl_dead_letter (error_class) WHERE status = 'open';
# config/error_routing.yaml — version-controlled; changes require a tracked CR.
# GxP: this file's git SHA is stamped into every run's audit header.
routing:
  transient_max_attempts: 5          # DLQ only after the retry policy gives up
  poison_max_redeliveries: 3         # cap unparseable payloads
  replay_cap: 3                      # max automatic replays per DLQ record
  quarantine_requires_signoff: true  # a CDM must approve before re-queue
alerting:
  dlq_open_warn: 25                  # page on-call when open DLQ rows exceed this
  dlq_open_page: 200                 # backpressure threshold: pause ingestion
  quarantine_age_hours_warn: 48      # aging validation failures need attention

Two rules keep this auditable. The DDL migration and the routing YAML are both controlled items whose git SHAs are stamped into each run’s audit header, so a reviewer can prove which policy governed a given failure. And the replay_cap in the config is enforced by the replay_count column in the DDL — a design where the guardrail lives in the durable store, not only in the worker that might be restarted mid-replay.

Testing and Validation

An error-handling path that has only ever been exercised by accident is not validated — the failure routes are precisely the part regulators care about, so you must inject faults deterministically and assert the routing. Use fault injection to prove each class lands in the correct store and that no record is ever lost.

# OQ artifact: proves permanent faults quarantine, transient faults retry-then-DLQ,
# and that a failed record is always durable somewhere — never silently dropped.
import pytest


def test_permanent_schema_fault_quarantines(engine, quarantine_tbl):
    # Inject a payload missing the required USUBJID (a SCHEMA_INVALID fault).
    bad = b'{"STUDYID":"CDP-101","VISITNUM":1}'   # no USUBJID
    route = process_record(engine, bad, run_id="run-oq-01")
    assert route is Route.QUARANTINE               # human review, never retried
    rows = fetch_rows(engine, quarantine_tbl)
    assert len(rows) == 1 and rows[0]["error_class"] == "schema_invalid"


def test_transient_fault_retries_then_dead_letters(engine, dlq_tbl, monkeypatch):
    # Inject a persistent 503 so the retry budget is fully consumed.
    monkeypatch.setattr("mypipeline.io.write_warehouse", _always_503)
    route = process_record(engine, VALID_PAYLOAD, run_id="run-oq-02")
    assert route is Route.DEAD_LETTER              # only after retries exhaust
    row = fetch_rows(engine, dlq_tbl)[0]
    assert row["error_class"] == "transient_infra"
    assert row["attempt_count"] == 5               # matches transient_max_attempts
    assert row["payload_sha256"] == sha256_of(VALID_PAYLOAD)  # ORIGINAL preserved


def test_poison_message_is_capped_not_looped(engine, dlq_tbl):
    # A payload that cannot be parsed must not redeliver forever.
    msg = FakeMessage(body=b"\x00\x01not-json", delivery_count=3)
    assert handle_poison(engine, dlq_tbl, msg, run_id="run-oq-03") is Route.DEAD_LETTER
    assert msg.acked is True                        # redelivery loop is broken

The mock fixtures should cover, at minimum: a clean load, a schema-invalid record, a validation-invalid record, a transient fault that recovers within budget, a transient fault that exhausts budget and dead-letters, a poison message that caps, and an auth failure that hard-stops. Each test’s structured log output is retained as objective OQ evidence, and the suite runs as a compliance gate in CI so a regression in routing blocks deployment.

Production Gotchas and Failure Modes

  • Infinite replay loop. A record is dead-lettered, an automated job replays it, it fails identically, and it is dead-lettered again — forever, consuming quota and burying real failures. Remediation: enforce the replay_count cap in the durable store (not only in the worker), and after the cap park the record in a terminal needs_engineering state that only a human can release.
  • DLQ growth and backpressure. During a prolonged vendor outage the DLQ fills faster than triage can drain it, and the escrow table itself becomes an operational risk. Remediation: alert on open-row count with two thresholds — a warn that pages on-call and a page that pauses ingestion entirely — so the pipeline applies backpressure rather than accumulating an unbounded, un-triageable backlog.
  • Losing the original bytes. Storing a parsed, normalized, or JSON-re-serialized version of the payload instead of the raw bytes destroys the Original record; a replay then re-processes a lossy copy and the payload_sha256 no longer matches what the EDC actually sent. Remediation: persist raw_payload verbatim as BYTEA and hash it before any transform touches it.
  • Replaying against a changed schema. A record dead-lettered last week is replayed today, but the target SDTM schema or controlled terminology has since changed; a blind replay silently writes a record that no longer conforms. Remediation: re-validate every DLQ record against the current schema at replay time, never against the schema captured when it failed — the mechanics are covered in Replaying Dead-Letter Queue Records in Clinical ETL.
  • Quarantine that nobody watches. A validation failure routed to quarantine with no aging alert sits invisibly until a CDM stumbles on it during database lock, by which point the query window may have closed. Remediation: alert on quarantine age and feed the records into the standing query-generation workflow so they surface as trackable discrepancies.

Compliance Checklist