Sizing Dead-Letter Queues for Claim Replay

A dead-letter queue (DLQ) that is sized wrong quietly becomes the single worst failure mode in an asynchronous adjudication pipeline: too shallow a retry budget discards a recoverable claim after one transient blip, while an unbounded DLQ with infinite retention swallows a poison message that loops until it exhausts broker memory and stalls live point-of-sale traffic. The decision this page settles is narrow and operational — how to set the retry budget (maxReceiveCount), the requeue backoff, the DLQ retention window, and the depth-alarm threshold so that a failed B1 claim can be replayed to a PAID or terminally-REJECTED disposition without loss, without duplication, and without letting one malformed payload take the queue down. It is the resilience layer that sits underneath Asynchronous Batch Adjudication Workflows: once a worker has exhausted its retries against a rate-limited formulary or pricing dependency, exactly where the message lands, how long it survives, and how it is drained back into the main queue all turn on these four numbers.

The Sizing Decision

DLQ sizing is a trade between recovering transient failures and isolating permanent ones. Set the retry budget too high and a genuine poison message — a claim that can never adjudicate — burns downstream API budget on every redelivery before it finally lands in the DLQ. Set it too low and a claim that hit a five-second formulary 503 gets one retry and is dead-lettered while the dependency was merely draining a deploy. The four levers below are tuned together against the shape of your failure distribution, not in isolation.

Strategy maxReceiveCount Requeue backoff DLQ retention Depth alarm Throughput / budget impact Replay footprint
Fast-fail (poison-heavy) 2 fixed 5s 4 days > 50 msgs Low downstream cost; risks dead-lettering transient 503s Small DLQ, cheap replay
Balanced default 5 exp 2^n, cap 60s 14 days > 200 msgs Absorbs most 429/503 blips inside the budget ~5x message-size headroom
Conservative (dependency-flaky) 8 exp 2^n, cap 300s 14 days > 500 msgs High redelivery cost; longest recovery window Large DLQ; replay must be throttled
Poison-isolating 3 + content hash exp 2^n, cap 60s 30 days any repeat hash Detects non-transient failures early by fingerprint Small; DLQ is inspection queue only

Two numbers anchor the sizing. First, size the DLQ retention to at least twice your longest realistic incident window plus the manual-fix turnaround — a 14-day retention covers a Friday-night dependency outage that is not root-caused until the following week. Second, size the depth alarm below the point where DLQ volume implies a systemic fault rather than scattered bad claims: for a stream doing 400 claims/second, a DLQ climbing past a few hundred messages in a minute is a broken snapshot push or a dead dependency, not random noise, and it should page before members feel it at the counter. The transport underneath — Kafka vs RabbitMQ for PBM Adjudication — changes the mechanics (a Kafka retry topic ladder versus a broker-native DLX), but the four levers and their target values do not.

Dead-letter queue retry budget and replay loop A claim message is pulled from the main adjudication queue by a worker. If processing succeeds the claim reaches an adjudicated terminal state. If it fails, the message receive count is compared to maxReceiveCount: while the count is below the budget the message returns to the main queue after an exponential backoff visibility timeout, so transient formulary or pricing failures get bounded retries. Once the receive count exhausts the budget the message is dead-lettered into the DLQ, which has a bounded retention window and a depth alarm that pages operations when volume implies a systemic fault. A replay consumer drains the DLQ after the root cause is fixed, checks an idempotency key so an already-adjudicated claim is never re-billed, and requeues the survivors back onto the main queue. Main queue adj.pos.retail Worker adjudicate claim Process ok? Adjudicated PAID / REJECTED receiveCount < maxReceive? Backoff + requeue visibility timeout 2^n Dead-letter queue retention 14d · bounded Depth alarm → page systemic-fault threshold Replay consumer idempotent requeue yes fail yes retry no drain replay survivors

Figure: The retry budget bounds redeliveries — transient failures loop back through an exponential-backoff requeue until receiveCount exhausts maxReceiveCount, then the message is dead-lettered, alarmed on depth, and drained by an idempotent replay consumer once the root cause is fixed.

Step-by-Step Implementation

The worker below models a broker-agnostic message with a receive counter, applies a bounded exponential-backoff retry, dead-letters on budget exhaustion, and pairs with a replay consumer that is idempotent by construction. Each step names the NCPDP field it touches and honors the PHI boundary from Security & Compliance Boundaries for Claims Data.

1. Model the message with its receive count and a stable idempotency key. The key is derived from routing fields before any PHI is dropped, so a redelivered claim converges on one adjudication. The raw 302-C2 Cardholder ID is hashed at ingress and never carried on the message body.

2. Classify the failure before deciding to retry. A terminal reject (70 Product/Service Not Covered) must never be retried — retrying wastes downstream budget and can double-reject at the pharmacy. Only transient dependency failures (timeouts, 503, 429) consume the retry budget, exactly the distinction drawn in Handling PBM 404 and 503 Errors in Adjudication Scripts.

3. Requeue with exponential backoff while budget remains; dead-letter when it is exhausted. The visibility-timeout backoff caps at a ceiling so a flaky dependency does not stretch recovery to hours, and the DLQ landing carries the last error class for triage.

4. Replay idempotently. The replay consumer drains the DLQ, skips any claim whose idempotency key already reached ADJUDICATED, and requeues the rest — so a fixed poison message re-enters the pipeline exactly once.

python
from __future__ import annotations
import hashlib
import logging
from dataclasses import dataclass, field
from decimal import Decimal
from enum import Enum
from typing import Callable, Iterator, Optional

logger = logging.getLogger("pbm.dlq")

# Terminal NCPDP 511-FB reject codes must NOT be retried; only transient faults do.
TERMINAL_REJECTS = {"70", "75", "76", "79"}  # covered / PA / plan-limit / refill-too-soon


class FailureClass(str, Enum):
    TRANSIENT = "TRANSIENT"     # timeout, 503, 429 — consumes retry budget
    TERMINAL = "TERMINAL"       # deterministic reject — never retried
    POISON = "POISON"           # unparseable / schema fault — dead-letter immediately


@dataclass
class RetryPolicy:
    max_receive: int = 5              # maxReceiveCount before dead-lettering
    base_backoff_s: int = 2           # exponential base: 2 ** receive_count
    max_backoff_s: int = 60           # ceiling so recovery stays bounded


@dataclass
class ClaimMessage:
    bin: str                          # 101-A1 BIN Number (routing)
    pcn: str                          # 104-A4 Processor Control Number
    ndc: str                          # 407-D7 Product/Service ID (NDC-11)
    service_date: str                 # 401-D1 Date of Service (CCYYMMDD)
    quantity: Decimal                 # 442-E7 Quantity Dispensed — Decimal, never float
    cardholder_hash: str              # sha256 of 302-C2, hashed at ingress (PHI-safe)
    receive_count: int = 0
    last_error: Optional[str] = None

    @property
    def idempotency_key(self) -> str:
        # Derived from routing fields + hashed member; stable across redeliveries.
        raw = f"{self.bin}:{self.pcn}:{self.cardholder_hash}:{self.ndc}:{self.service_date}"
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]


def hash_cardholder(raw_302c2: str) -> str:
    # 302-C2 Cardholder ID is PHI; hash at ingress, never log or persist the raw value.
    return hashlib.sha256(raw_302c2.encode("utf-8")).hexdigest()[:16] if raw_302c2 else ""


class ReplayableWorker:
    """Bounded-retry worker that dead-letters on budget exhaustion."""

    def __init__(self, policy: RetryPolicy, adjudicate: Callable[[ClaimMessage], FailureClass]):
        self._policy = policy
        self._adjudicate = adjudicate

    def backoff_seconds(self, receive_count: int) -> int:
        # Exponential visibility timeout, capped at the policy ceiling.
        return min(self._policy.base_backoff_s * (2 ** receive_count), self._policy.max_backoff_s)

    def handle(self, msg: ClaimMessage, main_queue: list, dlq: list) -> str:
        outcome = self._adjudicate(msg)

        if outcome is FailureClass.TERMINAL:
            # Deterministic reject — resolved, do not retry, do not dead-letter.
            logger.info("terminal", extra={"key": msg.idempotency_key, "code": msg.last_error})
            return "TERMINAL"

        if outcome is FailureClass.POISON:
            # Schema/parse fault: no retry can fix it — dead-letter on first sight.
            dlq.append(msg)
            logger.warning("poison_dlq", extra={"key": msg.idempotency_key})
            return "DLQ_POISON"

        # TRANSIENT: consume one unit of the retry budget.
        msg.receive_count += 1
        if msg.receive_count >= self._policy.max_receive:
            dlq.append(msg)
            logger.warning("budget_exhausted_dlq",
                           extra={"key": msg.idempotency_key, "receives": msg.receive_count})
            return "DLQ_EXHAUSTED"

        # Requeue after backoff (visibility timeout modeled by the delay field).
        msg.last_error = "TRANSIENT_DEPENDENCY"
        main_queue.append(msg)
        logger.info("requeue", extra={"key": msg.idempotency_key,
                                      "delay_s": self.backoff_seconds(msg.receive_count)})
        return "REQUEUED"


class ReplayConsumer:
    """Drains a DLQ back onto the main queue, exactly-once per idempotency key."""

    def __init__(self, adjudicated_keys: set[str]):
        self._adjudicated = adjudicated_keys  # keys that already reached ADJUDICATED

    def replay(self, dlq: Iterator[ClaimMessage], main_queue: list) -> dict[str, int]:
        stats = {"requeued": 0, "skipped_duplicate": 0}
        for msg in dlq:
            if msg.idempotency_key in self._adjudicated:
                stats["skipped_duplicate"] += 1        # never re-bill a settled claim
                continue
            msg.receive_count = 0                       # reset budget for the fixed cause
            main_queue.append(msg)
            stats["requeued"] += 1
        return stats

The worker is a pure function of the message and policy: the only mutable state is receive_count on the message, so it is safe to run across a horizontally scaled pool. Because the retry budget lives on the message rather than in worker memory, a worker restart mid-retry loses no accounting — the redelivered message still carries its count. Persistent dependency failure that opens a circuit breaker routes through Fallback Routing Logic Design rather than burning the whole retry budget against a dead service.

Verification and Testing Pattern

The properties that matter are: the budget is exact (no off-by-one that dead-letters early or retries forever), terminal rejects never re-enter the loop, and replay is idempotent. Drive each with a fixture that pins the receive count and failure class.

python
import pytest
from decimal import Decimal


def make_msg(**kw) -> ClaimMessage:
    base = dict(bin="123456", pcn="PCN01", ndc="00093015001",
                service_date="20260717", quantity=Decimal("30"),
                cardholder_hash=hash_cardholder("MEMBER-A"))
    base.update(kw)
    return ClaimMessage(**base)


def test_transient_retries_then_dead_letters_at_budget():
    policy = RetryPolicy(max_receive=3)
    worker = ReplayableWorker(policy, adjudicate=lambda m: FailureClass.TRANSIENT)
    q, dlq = [], []
    msg = make_msg()
    # Two requeues (counts 1, 2), third attempt exhausts the budget -> DLQ.
    assert worker.handle(msg, q, dlq) == "REQUEUED"
    assert worker.handle(msg, q, dlq) == "REQUEUED"
    assert worker.handle(msg, q, dlq) == "DLQ_EXHAUSTED"
    assert len(dlq) == 1 and msg.receive_count == 3


def test_terminal_reject_never_retries():
    worker = ReplayableWorker(RetryPolicy(), adjudicate=lambda m: FailureClass.TERMINAL)
    q, dlq = [], []
    msg = make_msg()
    assert worker.handle(msg, q, dlq) == "TERMINAL"
    assert q == [] and dlq == []          # no budget consumed, nothing dead-lettered


def test_poison_dead_letters_on_first_sight():
    worker = ReplayableWorker(RetryPolicy(), adjudicate=lambda m: FailureClass.POISON)
    q, dlq = [], []
    assert worker.handle(make_msg(), q, dlq) == "DLQ_POISON"
    assert len(dlq) == 1


def test_backoff_is_capped():
    worker = ReplayableWorker(RetryPolicy(base_backoff_s=2, max_backoff_s=60), adjudicate=lambda m: None)
    assert worker.backoff_seconds(3) == 16       # 2 * 2**3
    assert worker.backoff_seconds(10) == 60      # capped, not 2048


def test_replay_is_idempotent():
    settled = make_msg()
    consumer = ReplayConsumer(adjudicated_keys={settled.idempotency_key})
    q: list = []
    stats = consumer.replay(iter([settled, make_msg(ndc="00093015002")]), q)
    assert stats == {"requeued": 1, "skipped_duplicate": 1}
    assert len(q) == 1                            # the settled claim is not re-billed

The idempotency test is the load-bearing one: it pins the guarantee that draining a DLQ after an incident cannot double-bill a claim that already reached ADJUDICATED while the failed copy sat in the DLQ.

Gotchas and PHI Guardrails

  • Off-by-one on the budget. maxReceiveCount counts deliveries, not retries — a budget of 5 gives four retries after the first attempt. Test the exact boundary; a fencepost error here either dead-letters a recoverable claim early or grants one extra downstream call per claim, which multiplies into real API cost at volume.
  • Retrying terminal rejects. The most expensive DLQ defect is looping a 70/76/79 reject through the retry budget. These are deterministic — re-adjudicating produces the same reject and can surface a duplicate rejection at the pharmacy counter. Classify the failure before touching receive_count.
  • Unbounded retention. A DLQ with no retention cap is a memory leak with a schedule: poison messages accumulate until broker memory pressure stalls live queues. Set retention (14 days is the common default) and alarm on depth so the DLQ stays an inspection surface, not a graveyard.
  • Replay without idempotency. Draining a DLQ into the main queue after an outage will re-bill any claim that adjudicated on a duplicate delivery while the failed copy waited. Gate replay on the ADJUDICATED idempotency-key set, and reset receive_count only for the genuinely-unsettled survivors.
  • Backoff with no ceiling. Uncapped exponential backoff pushes a flaky-dependency claim’s next attempt hours out, blowing the claim-day window. Cap the visibility timeout (60s is typical) so recovery stays inside the reconciliation cycle.
  • PHI in the dead-letter payload. A message that lands in a DLQ is inspected by humans during triage — so the body must already be PHI-safe. Carry only the hashed 302-C2 reference, the 407-D7 NDC, and the last error class; never the raw claim bytes or the 310-CA Patient Name. A DLQ full of raw payloads is a reportable breach waiting for the first triage session. See the NCPDP Standards reference for the field definitions carried on the message.

← Back to Asynchronous Batch Adjudication Workflows