Skip to content
Ingestion & Parsing

Exception Handling and Dead-Letter Routing

An ingestion pipeline that drops a Driver Vehicle Inspection Report when a parser throws is not merely buggy — it has manufactured a compliance gap. Under 49 CFR § 396.3© the carrier must retain the record of every inspection and the maintenance action it triggers, so a DVIR that vanishes because a malformed VIN raised a ValidationError is a records-retention failure that no amount of downstream correctness can undo. The job of the exception layer inside the DVIR Ingestion & Digital/Paper Parsing Workflows architecture is therefore blunt: no inspection payload may ever leave the system without either becoming a valid record or landing, intact and inspectable, in a place a human is obligated to review. Silent loss is not an available outcome.

The engineering discipline that guarantees this is a strict separation of transient failures from permanent ones. A transient failure — a database deadlock, a timed-out OCR worker, a throttled telematics API — will likely succeed on a later attempt, so retry it with exponential backoff and a hard cap. A permanent failure — a payload that fails schema validation, an unresolvable defect code, a corrupt PDF — will fail identically forever, so retrying it only burns compute and delays the human decision it actually needs; route it to a dead-letter queue with enough context to diagnose and replay it. This page specifies that classifier, the failure envelope that preserves the original bytes for audit, the alerting that makes a growing dead-letter backlog impossible to ignore, and the replay path that re-enqueues a payload once the underlying defect is fixed. The concrete queue implementation — the failure envelope, the round-trip test, and idempotent replay — is built out in Designing a Dead-Letter Queue for Failed DVIR Payloads.

Fault-tolerant DVIR ingestion with retry and dead-letter routing A left-to-right flow. An ingested DVIR payload enters a processing stage that validates and parses it. On success the record is written to the audit store. On failure a classifier decides whether the error is transient or permanent. Transient failures route to a retry-with-backoff loop that feeds back into processing; once the attempt count exceeds the maximum retry cap the payload is treated as permanent. Permanent failures — and retry-exhausted payloads — are wrapped in an IngestionFailure envelope and routed to the dead-letter queue, which raises an alert and holds the original bytes for human review. A reviewer fixes the underlying defect and triggers a replay that re-enqueues the preserved payload back into processing. No path discards a payload. INGEST DVIR payload queued event PROCESS Validate · parse schema · normalize CLASSIFY error? transient/perm Audit store valid record written Retry · backoff attempt < max cap DEAD-LETTER Dead-letter queue IngestionFailure envelope alert · hold original bytes Human review fix defect · replay Re-enqueue idempotent replay ok transient retry cap hit permanent

Prerequisites and Environment Setup

Anchor link to "Prerequisites and Environment Setup"

Target Python 3.10+ — the classifier below uses StrEnum, structural match/case, and X | Y unions. The exception layer sits between the ingestion channels and the canonical record store, so it depends on the same contracts as the rest of the pipeline rather than inventing its own. The stack is:

  • pydantic (v2) — model the IngestionFailure envelope and validate every payload before it is admitted; a ValidationError here is the canonical permanent failure signal.
  • tenacity — declarative retry with exponential backoff and jitter, so transient-failure retry policy is code you can read and test rather than hand-rolled sleep loops.
  • A durable broker — Redis Streams or Amazon SQS backs both the work queue and the dead-letter queue. The queue mechanics are the subject of the dead-letter queue design guide; this page is the policy layer above it.
  • httpx — outbound calls to telematics and CMMS platforms whose timeouts and 5xx responses are the most common transient failures.

The exception layer does not define the record schema. A payload is permanently failed the moment it cannot satisfy the canonical contract from Standardized DVIR JSON Schema Design, and the original bytes it preserves are hashed and retained under the same rules the Immutable Audit Trail and WORM Retention guide specifies for admitted records. Failures produced during high-volume bursts from the Async Batching for High-Volume Ingestion stage flow through exactly this classifier before anything is retried or dead-lettered.

Every failure is normalized into a single IngestionFailure envelope. The envelope is not a log line — it is a durable, replayable record that preserves the original payload by reference, captures where and why processing stopped, and tracks how many times it has been attempted. It is the only artifact a reviewer or an auditor needs to understand and recover a failed inspection.

Field Type Enumeration / Range Compliance tag
payload_ref str content-addressed key (SHA-256) of original bytes Evidentiary anchor for § 396.3© retention
stage Stage ingest | validate | parse | normalize | dispatch Locates the failure in the pipeline
error_class ErrorClass transient | permanent Retry-vs-dead-letter decision
error_code str machine-stable symbol (e.g. schema.vin_length) Groups failures for alerting
attempt int ≥ 1 Retry-cap enforcement
first_seen datetime UTC, tz-aware Backlog age / SLA clock
last_error str exception summary (no raw PII) Human diagnosis
disposition Disposition retrying | dead_letter | replayed | resolved Lifecycle state
python
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, Field

MAX_RETRIES = 5  # hard cap; beyond this a transient failure is treated as permanent

class Stage(StrEnum):
    INGEST = "ingest"
    VALIDATE = "validate"
    PARSE = "parse"
    NORMALIZE = "normalize"
    DISPATCH = "dispatch"

class ErrorClass(StrEnum):
    TRANSIENT = "transient"   # retry with backoff
    PERMANENT = "permanent"   # route to dead-letter for human review

class Disposition(StrEnum):
    RETRYING = "retrying"
    DEAD_LETTER = "dead_letter"
    REPLAYED = "replayed"
    RESOLVED = "resolved"

class IngestionFailure(BaseModel):
    payload_ref: str = Field(pattern=r"^[0-9a-f]{64}$")  # SHA-256 of the original bytes
    stage: Stage
    error_class: ErrorClass
    error_code: str
    attempt: int = Field(ge=1)
    first_seen: datetime
    last_error: str
    disposition: Disposition

The payload_ref is a content hash, not a copy of the payload inside the envelope. Store the original bytes once in a write-once object store keyed by that hash, and the envelope stays small, deduplicates re-uploads of the same failing DVIR, and still lets an auditor retrieve the exact bytes that failed. Never inline the raw payload into the envelope that fans out to logs and alerts — that is how driver PII leaks into systems that were never scoped to hold it.

Core Algorithm: Classify, Retry, Dead-Letter, Replay

Anchor link to "Core Algorithm: Classify, Retry, Dead-Letter, Replay"

The exception layer is a state machine with one rule: a payload is either progressing toward a valid record, waiting on a bounded retry, or sitting in the dead-letter queue awaiting a human. It is never simply gone.

1. Classify the failure. Map the caught exception to transient or permanent from an explicit table, not from a bare except Exception. Anything you cannot positively classify as transient is permanent — default to the dead-letter queue, because retrying an unclassifiable error risks an infinite loop, while dead-lettering it only costs one human review.

python
import httpx
from pydantic import ValidationError

# Positive list of transient exception types. Everything else is permanent.
TRANSIENT_TYPES: tuple[type[Exception], ...] = (
    httpx.TimeoutException,
    httpx.ConnectError,
    ConnectionResetError,
    TimeoutError,
)

def classify(exc: Exception) -> ErrorClass:
    match exc:
        case ValidationError() | ValueError() | KeyError():
            return ErrorClass.PERMANENT     # bad data fails identically forever
        case httpx.HTTPStatusError() as e:
            # 429 and 5xx are transient; 4xx (except 429) are a permanent client fault.
            code = e.response.status_code
            transient = code == 429 or 500 <= code < 600
            return ErrorClass.TRANSIENT if transient else ErrorClass.PERMANENT
        case _ if isinstance(exc, TRANSIENT_TYPES):
            return ErrorClass.TRANSIENT
        case _:
            return ErrorClass.PERMANENT     # unknown -> dead-letter, never retry blindly

2. Retry transient failures with capped exponential backoff. A transient failure gets a bounded number of attempts with exponentially increasing, jittered delays so a downstream outage does not turn into a retry storm the instant it recovers. Cap the attempts: once attempt exceeds MAX_RETRIES, reclassify the payload as permanent and dead-letter it. An unbounded retry is indistinguishable from silent loss — the record never lands anywhere a human looks.

python
from tenacity import (
    retry, stop_after_attempt, wait_exponential_jitter, retry_if_exception_type,
)

@retry(
    retry=retry_if_exception_type(TRANSIENT_TYPES),
    wait=wait_exponential_jitter(initial=1, max=60),  # 1s, 2s, 4s ... capped, jittered
    stop=stop_after_attempt(MAX_RETRIES),
    reraise=True,   # after the cap, re-raise so the caller can dead-letter
)
def process_with_retry(payload: bytes) -> dict:
    return normalize_dvir(payload)  # raises ValidationError (permanent) or httpx errors (transient)

3. Route permanent failures to the dead-letter queue. Wrap the payload reference and error context in an IngestionFailure, persist it, and stop. Do not delete the source payload, do not emit a partial record, and do not swallow the exception. The envelope’s disposition is dead_letter and its first_seen starts the backlog-age clock that alerting watches.

python
import hashlib
from datetime import datetime, timezone

def to_dead_letter(payload: bytes, stage: Stage, exc: Exception, attempt: int) -> IngestionFailure:
    return IngestionFailure(
        payload_ref=hashlib.sha256(payload).hexdigest(),  # bytes stored once, keyed by hash
        stage=stage,
        error_class=ErrorClass.PERMANENT,
        error_code=f"{stage}.{type(exc).__name__}",
        attempt=attempt,
        first_seen=datetime.now(timezone.utc),
        last_error=str(exc)[:500],   # summary only — never the raw payload
        disposition=Disposition.DEAD_LETTER,
    )

4. Replay after the fix. When a reviewer or a code fix resolves the root cause — a corrected layout map, a widened schema enum, a recovered downstream — re-enqueue the preserved bytes through the same processing path. Replay must be idempotent: re-processing a payload that partially succeeded before must not create a second compliance record. Key the record write on the payload_ref so a replay of an already-admitted inspection resolves to the existing record instead of duplicating it. The end-to-end round-trip and idempotent replay are implemented and tested in Designing a Dead-Letter Queue for Failed DVIR Payloads.

Compliance Thresholding and Routing

Anchor link to "Compliance Thresholding and Routing"

The classifier’s output maps directly to an obligation, not a preference. Treat the routing table below as a compliance contract: each failure state has exactly one correct destination, and “drop it” is never a cell in the table.

Failure state Condition Required action
Transient, within cap error_class == transient and attempt ≤ MAX_RETRIES Retry with backoff; keep disposition = retrying
Retry exhausted error_class == transient and attempt > MAX_RETRIES Reclassify permanent; route to dead-letter; alert
Permanent error_class == permanent Route to dead-letter immediately; hold original bytes; alert
Poison message payload repeatedly crashes the worker itself Quarantine to dead-letter after the first crash; never re-deliver blindly

A poison message — a payload that does not merely fail validation but crashes the worker process (an unparseable PDF that segfaults a native OCR binary, a decompression bomb) — is the most dangerous case, because a naive broker will redeliver it to the next worker and take the whole pool down. Detect it by tracking delivery count on the message itself and quarantine to the dead-letter queue after the first crash rather than the fifth. A DVIR that crashes a worker is still a DVIR the carrier must retain; quarantine preserves it for review without letting it deny service to every other inspection in the queue.

The retention obligation outlives the failure. A dead-lettered DVIR is a record within the meaning of § 396.3© — its original bytes must be retained and hash-anchored exactly as an admitted record would be, and its lifecycle (dead_letterreplayedresolved) must be captured in the tamper-evident event chain described in Audit Logging and Tamper-Evident Event Chains. If an auditor asks what happened to an inspection that a driver swears they submitted, the answer must be a durable envelope, not a shrug.

Production Integration and Platform Synchronization

Anchor link to "Production Integration and Platform Synchronization"

The exception layer is a shared service, not a per-consumer afterthought. Every ingestion channel — the OCR pipeline, the mobile export endpoint, the field-mapping normalizer — hands its failures to the same classifier and the same dead-letter queue, so there is one place to watch a backlog, one alerting policy, and one replay tool. Emit a structured event on every disposition change (retrying, dead_letter, replayed, resolved) onto the audit stream so the backlog is observable in real time rather than discovered during an audit.

Alerting must be driven by the two signals that predict compliance risk: backlog age and error-code concentration. A single dead-lettered DVIR is routine; a dead-letter queue whose oldest envelope is four hours old, or a sudden spike of one error_code (a carrier changed its form layout and every VIN crop now fails), is an incident. Page on both. Because outbound platform calls to telematics and CMMS systems are the dominant source of transient failures, coordinate this layer’s retry policy with the delivery-side retries in Idempotent Webhook Retry and Signature Verification so the same event is not retried twice at two layers and double-fires an out-of-service hold. Deduplicate every retry and every replay on the payload_ref content hash; the severity bands that any recovered defect maps to are unchanged from the rest of this section: 0–34 minor, 35–69 major, 70–100 critical, with any DOT_OOS_CRITERIA defect held at the immovable critical floor of 70.

  • No silent drops: every failure produces a durable IngestionFailure envelope; there is no code path that catches an exception and discards the payload.
  • Positive transient classification: only exceptions on an explicit allow-list are retried; everything unclassified is dead-lettered, never retried blindly.
  • Bounded retries: transient retries use capped exponential backoff with jitter and a hard MAX_RETRIES; exhaustion reclassifies to permanent.
  • Poison-message quarantine: a payload that crashes the worker is quarantined after the first crash on its delivery count, protecting the worker pool.
  • Bytes preserved by reference: the original payload is stored once, keyed by SHA-256, and referenced from the envelope — never inlined into logs or alerts where PII would leak.
  • Idempotent replay: re-enqueuing a payload keys the record write on payload_ref, so replay cannot create a duplicate compliance record.
  • Backlog alerting: dead-letter backlog age and per-error_code spikes both page; a growing queue is an incident, not a metric nobody reads.
  • Audit-linked lifecycle: every disposition change is written to the tamper-evident event chain to satisfy § 396.3© retention.
When should a failed DVIR be retried versus dead-lettered?

Retry only failures you can positively classify as transient — network timeouts, connection resets, HTTP 429 and 5xx from a downstream platform — because those are likely to succeed on a later attempt. Route everything else to the dead-letter queue: schema-validation errors, unresolvable defect codes, and corrupt payloads fail identically on every attempt, so retrying them only burns compute and delays the human decision they need. Anything you cannot positively classify as transient is treated as permanent and dead-lettered.

How do you keep a poison message from taking down the worker pool?

Track the delivery count on the message itself and quarantine a payload to the dead-letter queue after its first crash, not after repeated redeliveries. A poison message — one that crashes the worker process rather than merely failing validation — will otherwise be redelivered to the next worker and cascade through the whole pool. Quarantining on the first crash preserves the DVIR for human review while stopping it from denying service to every other inspection waiting in the queue.

Why not just log the exception and move on?

Because a DVIR that disappears is a records-retention failure under 49 CFR § 396.3©, not a recoverable bug. A log line is not a record: it is not replayable, it does not preserve the original bytes for audit, and nobody is obligated to act on it. The dead-letter envelope is durable, references the exact bytes that failed, tracks its own lifecycle, and lands in a queue a human must clear — which is the difference between a recoverable failure and a silent compliance gap.

How does replay avoid creating duplicate compliance records?

Make replay idempotent by keying the record write on the payload’s content hash. When a reviewer re-enqueues a dead-lettered payload after fixing the root cause, re-processing a payload that had partially succeeded before must resolve to the existing record rather than insert a second one. Because the payload_ref is a SHA-256 of the original bytes, the same inspection always maps to the same key, so a replay is safe to run as many times as needed.

Back to DVIR Ingestion & Digital/Paper Parsing Workflows.