Routing failed payloads to quarantine and a dead-letter queue

When a maritime payload fails validation, the expensive mistake is treating every failure the same. Routing failed payloads to quarantine and a dead-letter queue is the decision layer that inspects each rejected interchange and sends it to exactly one destination: a dead-letter queue for unrecoverable structural corruption that only engineering can fix, or a quarantine topic for a parsed-but-non-compliant record that an ops team can reconcile against a refreshed registry and replay. Collapse those two paths into one and you either drop legally valid Bills of Lading or drown your on-call engineers in recoverable business exceptions. This page specifies the classifier, the immutable audit record, and the replay loop that keep the two failure modes strictly separated.

Architecture Alignment

This routing layer is the failure half of the gate defined by Schema Validation Frameworks, the reference that owns synchronous structural, semantic, and regulatory checks inside the wider Document Ingestion & EDI Parsing Workflows domain. The validation tiers decide whether a payload passes; this component decides where a failure goes once it does not. It runs immediately after a tier raises, and it is deliberately narrow: it classifies an exception into a routing verdict, writes one immutable audit record, and publishes to a broker destination — nothing else. Durable delivery, retry backoff, and worker back-pressure for those destinations belong to Async Batch Processing Pipelines, and the concrete Redis dead-letter exchange and quarantine topic are provisioned by building Celery queues for maritime doc ingestion. This page owns only the classification and audit contract that sits between them.

Failure-routing fan-out from validation verdict to DLQ, quarantine, and audit-hold A failed payload carrying a correlation id enters the route_failure classifier. The classifier writes one immutable audit record keyed on correlation id and payload sha256, then fans the verdict out to one of three destinations: a dead-letter queue for unrecoverable structural corruption, a quarantine topic for a recoverable parsed-but-non-compliant record, or an audit-hold for a payload that has exceeded its poison-message cap. From the quarantine topic a reconcile-and-replay loop returns the corrected payload to intake for re-validation. structural recoverable poison cap reconcile · replay → intake Failed payload correlation_id route_failure() classify verdict Dead-letter queue structural corruption Quarantine topic ops reconcile Audit-hold poison-message cap Immutable audit id + sha256
route_failure() writes one immutable audit record per failure, then fans the verdict to the dead-letter queue (structural), the quarantine topic (recoverable), or an audit-hold (poison-message cap); reconciled quarantine records replay back to intake.

Prerequisites & Environment Setup

The routing layer targets Python 3.11+ for enum.StrEnum, exception groups, and datetime.UTC. Install the broker client, structured logging, and typed-model stack:

pip install "celery[redis]==5.4.*" redis structlog "pydantic>=2.6"

Every routing decision must be reconstructable during a customs or port-state-control audit, so bare print() is unacceptable — each verdict is an event queried later by correlation_id. Configure structlog once at worker boot to emit JSON:

import structlog

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
)
log = structlog.get_logger("maritime.routing")

The router reads its destinations and its poison cap from the environment so the same code runs against RabbitMQ or Redis without edits:

Variable Purpose Example
INGEST_DLX Dead-letter exchange for unrecoverable structural corruption edi.dlx
INGEST_QUARANTINE_TOPIC Topic for recoverable parsed-but-non-compliant records edi.quarantine
INGEST_AUDIT_HOLD Topic for payloads over the poison-message cap edi.audit.hold
POISON_MAX_ATTEMPTS Replay attempts before a record is parked in audit-hold 5
AUDIT_STORE_DSN Append-only store for the immutable audit record postgres://audit/records

Step-by-step Implementation

Step 1 — Fix the error taxonomy

The routing decision is only as sound as the exception taxonomy behind it. Two families exist, and they map to two destinations. Structural failures mean the bytes never became a record: a broken UN/EDIFACT envelope with no UNB/UNZ pairing, an undecodable payload, or a pydantic.ValidationError on a mandatory segment such as a missing BGM. No human can reconcile these without engineering, so they go to the dead-letter queue. Semantic and regulatory failures mean the record parsed cleanly but breaks a business or compliance rule: an unknown UN/LOCODE, an ISO 6346 container check-digit that drifted under OCR, a stale HS/HTS tariff, a SOLAS Verified Gross Mass below the plausibility floor, or a non-standard NAD qualifier like NAD+CZ where a handler expected NAD+CN. These are recoverable against a refreshed registry and route to quarantine.

from __future__ import annotations

import enum


class FailureClass(enum.StrEnum):
    STRUCTURAL = "STRUCTURAL"     # envelope broken / mandatory segment absent
    SEMANTIC = "SEMANTIC"         # unknown LOCODE, ISO 6346 drift, bad NAD qualifier
    REGULATORY = "REGULATORY"     # stale tariff, VGM below SOLAS floor
    UNKNOWN = "UNKNOWN"           # unclassified — fail safe, do not drop


# Structural exceptions are the only ones that reach the DLQ. Everything a human
# can reconcile against a registry stays out of engineering's queue.
STRUCTURAL_EXC: tuple[type[Exception], ...] = (
    ValueError,            # pydantic.ValidationError subclasses ValueError
    UnicodeDecodeError,    # payload never decoded
    EOFError,              # truncated interchange / missing UNZ trailer
)

Step 2 — Build the immutable audit record

Before any payload moves, the router writes one append-only audit record so the decision is replayable even if the broker later loses the message. The record is keyed on the correlation_id plus the SHA-256 of the raw bytes: the correlation id ties the failure to its intake event, and the payload hash makes the record content-addressed so a replay of identical bytes collapses onto the same audit row instead of forging a second history. The model is frozen — an audit record is never mutated, only superseded by a later record sharing the same correlation id.

from __future__ import annotations

import hashlib
from datetime import UTC, datetime

from pydantic import BaseModel, ConfigDict, Field


class AuditRecord(BaseModel):
    """Append-only receipt for one failed-payload routing decision."""

    model_config = ConfigDict(frozen=True)

    correlation_id: str
    payload_sha256: str = Field(min_length=64, max_length=64)
    failure_class: str
    destination: str
    rule_field: str | None = None
    detail: str = ""
    attempt: int = Field(ge=1)
    recorded_at: datetime = Field(default_factory=lambda: datetime.now(UTC))

    @classmethod
    def from_payload(cls, raw: bytes, **kw: object) -> "AuditRecord":
        digest = hashlib.sha256(raw).hexdigest()
        return cls(payload_sha256=digest, **kw)  # type: ignore[arg-type]

Step 3 — Classify the failure and route the verdict

route_failure() is the whole decision. It maps a caught exception to a FailureClass, enforces the poison-message cap first so a record that has looped too many times is parked rather than requeued forever, writes the audit record, and returns the broker destination. Note the fail-safe: an exception that matches nothing known is treated as UNKNOWN and sent to quarantine for a human, never silently dropped — a rule the validation framework shares, because an unclassified failure is a gap in the taxonomy, not a licence to discard cargo data.

from __future__ import annotations

import os

import structlog

log = structlog.get_logger("maritime.routing")

DLX = os.environ.get("INGEST_DLX", "edi.dlx")
QUARANTINE = os.environ.get("INGEST_QUARANTINE_TOPIC", "edi.quarantine")
AUDIT_HOLD = os.environ.get("INGEST_AUDIT_HOLD", "edi.audit.hold")
POISON_MAX = int(os.environ.get("POISON_MAX_ATTEMPTS", "5"))


def _classify(exc: Exception) -> FailureClass:
    if isinstance(exc, STRUCTURAL_EXC):
        return FailureClass.STRUCTURAL
    if isinstance(exc, (KeyError, LookupError)):
        return FailureClass.SEMANTIC     # unknown LOCODE / NAD qualifier lookup miss
    if isinstance(exc, PermissionError):
        return FailureClass.REGULATORY   # tariff / VGM rule breach
    return FailureClass.UNKNOWN


def route_failure(
    raw: bytes,
    exc: Exception,
    *,
    correlation_id: str,
    attempt: int,
    field: str | None = None,
) -> AuditRecord:
    log.bind(correlation_id=correlation_id, attempt=attempt)
    failure = _classify(exc)

    # Poison-message cap wins over class: a record that keeps failing is parked.
    if attempt >= POISON_MAX:
        destination = AUDIT_HOLD
        log.error("poison_cap_reached", failure_class=failure, attempt=attempt)
    elif failure is FailureClass.STRUCTURAL:
        destination = DLX
        log.error("dead_letter_routed", failure_class=failure, detail=str(exc))
    else:
        destination = QUARANTINE
        log.warning("quarantine_routed", failure_class=failure, field=field)

    record = AuditRecord.from_payload(
        raw,
        correlation_id=correlation_id,
        failure_class=str(failure),
        destination=destination,
        rule_field=field,
        detail=str(exc)[:500],
        attempt=attempt,
    )
    return record

Step 4 — Publish with the audit record attached, then reconcile and replay

The verdict is worthless until it is durably published, and the audit receipt must ride with the message so ops can act on it from the quarantine topic alone. The publisher below is idempotent on the payload hash, so an at-least-once broker redelivery does not create a duplicate quarantine entry. Reconciliation reverses the flow: an ops tool pulls a quarantine record, resolves the offending field — refreshes the UN/LOCODE snapshot, corrects the ISO 6346 check digit, remaps the NAD qualifier — and replays the corrected payload back to intake with its original correlation_id and an incremented attempt. That single loop is what lets a stale-registry failure clear itself once the async batch pipeline refreshes its cache, without engineering ever touching the message.

from __future__ import annotations

import json

import structlog

log = structlog.get_logger("maritime.routing")


def publish_failure(broker, record: AuditRecord, raw: bytes) -> None:
    audit_store.upsert(record)  # keyed on (correlation_id, payload_sha256): replays no-op
    broker.publish(
        record.destination,
        raw,
        headers={
            "correlation_id": record.correlation_id,
            "payload_sha256": record.payload_sha256,
            "failure_class": record.failure_class,
            "attempt": record.attempt,
            "audit": json.loads(record.model_dump_json()),
        },
    )
    log.info(
        "failure_published",
        correlation_id=record.correlation_id,
        destination=record.destination,
        attempt=record.attempt,
    )


def replay_from_quarantine(broker, record: AuditRecord, raw: bytes) -> None:
    # Ops has reconciled the offending field; send corrected bytes back to intake.
    broker.publish(
        "edi.intake",
        raw,
        headers={"correlation_id": record.correlation_id, "attempt": record.attempt + 1},
    )
    log.info("quarantine_replayed", correlation_id=record.correlation_id)

Edge Cases & Carrier Deviations

Symptom Root cause Fix
Valid B/L lands in the DLQ ValidationError raised on an optional segment treated as mandatory Loosen the model; only mandatory-segment failures are STRUCTURAL
Quarantine topic never drains No reconcile loop; records published but never replayed Wire replay_from_quarantine; alert when topic age exceeds SLA
Same payload requeued forever Poison cap not enforced before class routing Check attempt >= POISON_MAX first; park in audit-hold
Duplicate quarantine entries Non-idempotent publish under at-least-once delivery Upsert audit on (correlation_id, sha256); publishes collapse
NAD+CZ hard-dropped Unknown party qualifier raised and caught as fatal Classify as SEMANTIC; quarantine so a human reconciles
Audit record disagrees with replay Record mutated in place on retry Keep AuditRecord frozen; supersede, never overwrite

The subtlest deviation is the non-standard NAD qualifier. A carrier that emits NAD+CZ (consignee’s agent) where your handler expects NAD+CN (consignee) produces a lookup miss, not corrupt bytes. The document is legally valid EDIFACT; classifying that miss as STRUCTURAL and dead-lettering it would strand a shippable consignment behind an engineering ticket. It belongs in quarantine, where an ops analyst maps the qualifier and replays — the exact behaviour the _classify LookupError branch encodes.

Verification & Testing

Assert that structural faults dead-letter, recoverable faults quarantine, and the poison cap parks a looping record. Use byte fixtures so the SHA-256 keying is exercised, and structlog’s capture helper to assert the audit event fires with the right destination.

import pytest
import structlog

from maritime_routing.router import FailureClass, route_failure


@pytest.fixture
def raw() -> bytes:
    return b"UNB+UNOA:2'UNH+1+IFCSUM:D:96A:UN'"


def test_structural_goes_to_dlq(raw: bytes) -> None:
    rec = route_failure(raw, ValueError("missing BGM"), correlation_id="c1", attempt=1)
    assert rec.failure_class == FailureClass.STRUCTURAL
    assert rec.destination.endswith("dlx")


def test_unknown_locode_quarantines(raw: bytes) -> None:
    rec = route_failure(raw, KeyError("USNYC"), correlation_id="c2", attempt=1, field="LOC")
    assert rec.destination.endswith("quarantine")


def test_poison_cap_parks_record(raw: bytes) -> None:
    rec = route_failure(raw, KeyError("USNYC"), correlation_id="c3", attempt=5)
    assert rec.destination.endswith("audit.hold")


def test_audit_event_shape(raw: bytes) -> None:
    cap = structlog.testing.LogCapture()
    structlog.configure(processors=[cap])
    route_failure(raw, ValueError("broken envelope"), correlation_id="c4", attempt=1)
    assert cap.entries[-1]["event"] == "dead_letter_routed"

A passing run emits one JSON line per verdict; a dead-letter decision looks like {"event": "dead_letter_routed", "correlation_id": "c4", "failure_class": "STRUCTURAL", "attempt": 1, "log_level": "error", "timestamp": "..."}. Assert on the structured keys, never on a formatted message string.

Frequently Asked Questions

When does a failed payload go to the DLQ versus the quarantine topic?

Dead-letter only unrecoverable structural corruption a human cannot fix without engineering — a broken UN/EDIFACT envelope, an undecodable payload, or a pydantic.ValidationError on a mandatory segment. Quarantine everything that parsed cleanly but failed a business or compliance rule an ops team can reconcile: an unknown UN/LOCODE, an ISO 6346 check-digit that drifted under OCR, a stale tariff, a VGM below the SOLAS floor, or a non-standard NAD qualifier. Mixing the two either drops legally valid interchanges or floods engineering with recoverable exceptions.

Why key the audit record on correlation id plus payload hash?

The correlation id ties the failure back to its intake event so a replay carries one continuous history; the SHA-256 of the raw bytes makes the record content-addressed, so an at-least-once broker redelivery of identical bytes upserts onto the same audit row instead of forging a duplicate decision trail. Together they give a customs or port-state-control auditor a single, immutable, replayable answer to why a document was rejected, quarantined, or replayed.

What stops a poison message from being replayed forever?

A poison-message cap checked before class routing. Each replay increments an attempt counter carried in the message header; once it reaches POISON_MAX_ATTEMPTS the router parks the record in an audit-hold topic regardless of its failure class, and pages ops. That prevents a payload that fails identically on every attempt — a genuinely un-reconcilable record misfiled as recoverable — from cycling through quarantine and burning broker capacity indefinitely.

Up: Schema Validation Frameworks — the validation gate this failure-routing layer completes.