Routing Malformed HL7 Messages to a Dead-Letter Queue
Problem Statement
When an HL7 v2 message fails to parse — a truncated MSH, a segment in the wrong order, a value that will not coerce to the type its OBX field demands — the pipeline faces exactly two safe options and one dangerous one. It can quarantine the message untouched for a human to fix, or it can reject and stop; what it must never do is drop the message silently or coerce the bad value into something that parses, because both fabricate a clinical record’s fate without anyone deciding it. A dropped result is a test the ordering physician believes is pending forever; a coerced result is a wrong number on a chart. This page implements the quarantine path: on any parse or schema failure, write the raw message plus structured failure metadata to a dead-letter queue, raise an alert, and keep enough context that after the root cause is fixed the message can be re-driven through the same validation without data loss. It is the failure branch of the Schema Validation & Error Handling stage.
Prerequisites
Before wiring a dead-letter branch into the validation path, confirm the baseline:
- Runtime: Python 3.11+,
pydantic>=2.6for the failure-record model, andpytest>=8for the fixtures. - A durable quarantine store: a queue, table, or object store that is append-only and retains the raw bytes verbatim — not a re-serialized copy. The whole value of a dead-letter queue is that it holds what actually arrived.
- A stable tracking id: every inbound message already carries an immutable
tracking_idassigned at acquisition by the Serial / FTP Polling Architectures layer, so a quarantined message can be correlated end to end. - A validation stage that rejects rather than coerces: the library choice is covered in Pydantic v2 vs Cerberus for clinical schema validation; either is fine as long as it refuses silent coercion so failures actually reach this branch.
Step-by-Step Implementation
Step 1: Model the dead-letter record
The quarantine record must capture everything needed to diagnose and re-drive: the raw bytes as received, the failure category, a structured error payload, and provenance. Model it with Pydantic v2 so a malformed quarantine record can never be written either — the failure path deserves the same rigor as the success path.
from __future__ import annotations
import base64
import datetime as dt
from enum import Enum
from pydantic import BaseModel, Field, field_serializer
class FailureStage(str, Enum):
parse = "parse" # could not split into segments/fields
structure = "structure" # segments present but cardinality/order wrong
schema = "schema" # fields present but a value failed its constraint
class DeadLetter(BaseModel):
tracking_id: str
source: str # e.g. "chem-1" or an interface name
raw: bytes # the message exactly as received
stage: FailureStage
errors: list[dict[str, str]] # normalized [{loc, type, msg}, ...]
received_at: dt.datetime
quarantined_at: dt.datetime = Field(
default_factory=lambda: dt.datetime.now(dt.timezone.utc)
)
redrive_count: int = Field(default=0, ge=0)
@field_serializer("raw")
def _b64(self, v: bytes) -> str:
# base64 keeps non-UTF-8 analyzer bytes intact through JSON transport
return base64.b64encode(v).decode("ascii")
Storing raw as bytes and serializing to base64 is deliberate: analyzer output is frequently not valid UTF-8, and re-encoding it as text would corrupt the very evidence you are quarantining.
Step 2: Normalize the failure into a single taxonomy
Parse errors, structural errors, and schema errors arrive in different shapes. Normalize them into one errors list so alerting, dashboards, and re-drive tooling never have to care which layer failed. This is what lets a Pydantic ValidationError and a hand-written structural check feed the same quarantine.
from pydantic import ValidationError
def normalize_pydantic(exc: ValidationError) -> list[dict[str, str]]:
return [
{
"loc": ".".join(str(p) for p in e["loc"]),
"type": e["type"],
"msg": e["msg"],
}
for e in exc.errors()
]
def normalize_structural(reason: str, segment: str) -> list[dict[str, str]]:
return [{"loc": segment, "type": "structure", "msg": reason}]
Step 3: Route on failure — quarantine, never drop or coerce
The validation call is wrapped so that every failure path lands in the dead-letter queue with its raw bytes intact, and only a clean parse proceeds. There is no branch that returns a coerced value and no branch that swallows the message.
from typing import Protocol
class DeadLetterQueue(Protocol):
async def put(self, record: DeadLetter) -> None: ...
class Alerter(Protocol):
async def notify(self, record: DeadLetter) -> None: ...
async def validate_or_quarantine(
*,
raw: bytes,
tracking_id: str,
source: str,
received_at: dt.datetime,
parse: "callable[[bytes], object]",
dlq: DeadLetterQueue,
alerter: Alerter,
) -> object | None:
"""Return the parsed message, or None after quarantining the raw bytes."""
try:
return parse(raw) # raises ValidationError or a structural error
except ValidationError as exc:
record = DeadLetter(
tracking_id=tracking_id, source=source, raw=raw,
stage=FailureStage.schema, errors=normalize_pydantic(exc),
received_at=received_at,
)
except ValueError as exc:
record = DeadLetter(
tracking_id=tracking_id, source=source, raw=raw,
stage=FailureStage.parse, errors=normalize_structural(str(exc), "MSH"),
received_at=received_at,
)
await dlq.put(record)
await alerter.notify(record)
return None # explicit: the caller must treat None as "not committed"
Returning None rather than a default result is the safety hinge: the caller cannot mistake a quarantined message for a committed one, because there is no value to mistake.
Step 4: Support re-drive after the fix
Quarantine is only half the contract; the message must be recoverable. After the root cause is fixed — an interface mapping corrected, an analyzer firmware bug patched — the same raw bytes are replayed through the same validation. Incrementing redrive_count keeps the attempt history, and because the raw bytes are byte-for-byte what arrived, a successful re-drive produces exactly the result the analyzer originally sent.
async def redrive(
record: DeadLetter,
*,
parse: "callable[[bytes], object]",
dlq: DeadLetterQueue,
alerter: Alerter,
) -> object | None:
try:
message = parse(record.raw) # same validation, fixed environment
except (ValidationError, ValueError) as exc:
errors = (
normalize_pydantic(exc)
if isinstance(exc, ValidationError)
else normalize_structural(str(exc), "MSH")
)
requeued = record.model_copy(
update={"errors": errors, "redrive_count": record.redrive_count + 1}
)
await dlq.put(requeued)
await alerter.notify(requeued)
return None
return message # re-drive succeeded → hand to the idempotent commit path
A successful re-drive hands the message to the commit path, which must be idempotent so a message re-driven after a partial earlier attempt cannot double-commit.
Verification & Testing
The properties to prove are that a failure quarantines the exact bytes and that a re-drive of unchanged input stays quarantined while a re-drive after a fix succeeds. Use a stub queue and a parser that fails until an environment flag flips.
import base64
import datetime as dt
import pytest
from pydantic import BaseModel, ValidationError
class _Msg(BaseModel):
value: float
class StubDLQ:
def __init__(self) -> None:
self.records: list[DeadLetter] = []
async def put(self, record: DeadLetter) -> None:
self.records.append(record)
class StubAlerter:
def __init__(self) -> None:
self.count = 0
async def notify(self, record: DeadLetter) -> None:
self.count += 1
def _parse_bad(raw: bytes) -> object:
return _Msg.model_validate({"value": raw.decode("latin-1")}) # str -> float fails strict-ish
@pytest.mark.asyncio
async def test_malformed_is_quarantined_verbatim():
dlq, alerter = StubDLQ(), StubAlerter()
raw = b"MSH|^~\\&|bad\x00bytes"
out = await validate_or_quarantine(
raw=raw, tracking_id="T1", source="chem-1",
received_at=dt.datetime.now(dt.timezone.utc),
parse=_parse_bad, dlq=dlq, alerter=alerter,
)
assert out is None # not committed
assert alerter.count == 1 # on-call alerted
stored = dlq.records[0]
assert stored.raw == raw # bytes preserved exactly
# round-trips through base64 without corrupting the non-UTF-8 bytes
dumped = stored.model_dump_json()
assert base64.b64decode(DeadLetter.model_validate_json(dumped).raw) == raw
Expected: the test passes, out is None (the message is never treated as committed), the alert fires once, and the quarantined bytes equal the input exactly — including the embedded NUL — surviving the base64 JSON round trip. A second fixture should call redrive with a parser that now succeeds and assert it returns the parsed message with redrive_count incremented on the failed attempt only, proving recovery works without mutating history.
Compliance Note
Silent data loss is the specific failure a dead-letter queue exists to prevent, and it maps directly to regulation: CLIA §493.1291 requires that the laboratory information system convey complete and accurate results, which a dropped message violates by leaving an ordered test invisibly unresulted. 21 CFR Part 11 requires that electronic records be attributable and that the system not alter them undetectably — coercing a malformed value into a plausible one is exactly such an undetectable alteration. Quarantining the raw bytes verbatim, with the failure stage and normalized errors, produces the audit trail an inspector needs to confirm that every message either committed or is accounted for in the dead-letter queue. Retain quarantine records under the CLIA §493.1105 retention window, and record each redrive_count increment so the recovery history of any late-resulted specimen is reconstructable. The invariant to state in your SOP is blunt: a message is committed, quarantined, or rejected — never dropped, never coerced.
Troubleshooting
Quarantined messages fail to re-drive because the stored bytes are corrupted.
Root cause: the raw message was decoded to str and re-encoded before storage, mangling non-UTF-8 analyzer bytes. Fix: store raw as bytes and serialize with base64 as DeadLetter does; a re-drive then replays the exact original octets, including embedded control characters the analyzer emitted.
A malformed value is reaching the chart instead of the dead-letter queue.
Root cause: the validation stage coerces rather than rejects, so the failure never raises and never branches to quarantine. Fix: forbid silent coercion in the validator (see the comparison of Pydantic v2 and Cerberus), so a bad value raises ValidationError, which validate_or_quarantine routes to the dead-letter queue.
Re-driving the same message twice double-commits the result.
Root cause: the commit path is not idempotent, so a message re-driven after a partial earlier attempt inserts a duplicate. Fix: route successful re-drives through an idempotent, keyed upsert so replaying the same message converges on one row rather than two.
The on-call team is flooded with dead-letter alerts.
Root cause: alerting per message rather than per root cause, so one broken interface mapping pages hundreds of times. Fix: aggregate alerts by source and error type before notifying, and page once per new failure signature; the individual records still sit in the queue for re-drive once the mapping is fixed.
You cannot tell which messages are still awaiting re-drive.
Root cause: the dead-letter store is treated as a log, not a work queue, so resolved and unresolved records are indistinguishable. Fix: track redrive_count and a resolution state per record, and only clear a record from the active queue once a re-drive commits — the append-only history stays, but the outstanding-work view is accurate.
Related
- Pydantic v2 vs Cerberus for Clinical Schema Validation — how the validation layer that feeds this quarantine refuses silent coercion in either library.
- Schema Validation & Error Handling — the stage contract this dead-letter branch completes, including the tiered error taxonomy.
- Validating ASTM E1394 Instrument Output with Python — a concrete validation build whose quarantine routing follows this same pattern for a framed protocol.
- Instrument Data Ingestion & HL7/CSV Pipelines — the ingestion tier where the no-silent-loss invariant is owned end to end.
Part of: Schema Validation & Error Handling.