Retry Semantics, Dead-Letter Queues & Idempotency Keys for Payroll Runs

A retried payroll message is either a duplicate about to overpay an employee or a permanent failure about to loop forever — and the only thing standing between those two outcomes is whether the retry layer classifies the failure correctly before it acts. This page specifies the retry, dead-letter, and idempotency layer that sits underneath the Async Batch Processing topic within the Multi-Format Payroll Data Ingestion & Normalization framework: how to tell a transient failure from a permanent one, back off with jitter inside a hard budget, route exhausted or poison messages to a dead-letter queue with a machine-readable reason code, and re-drive them safely once the root cause is fixed.

Problem Framing

Payroll runs sit on top of message transports — a durable queue, a stream, a task broker — that guarantee at-least-once delivery, never exactly-once. That guarantee is the correct tradeoff for throughput, but it means every consumer of a payroll message must manufacture its own exactly-once effect, and three shortcuts routinely break that guarantee in production:

  • Retrying without classifying the failure. A schema-validation error and a database timeout look identical to a bare except Exception: retry() block, so a permanently malformed record retries forever, burning the retry budget meant for real transient failures and delaying the dead-letter escalation that should have paged a human immediately.
  • Deriving the idempotency key from the retry itself. A key built from datetime.now() or uuid4() is unique per attempt by construction, which defeats the entire point of an idempotency key: every retry of the same underlying disbursement looks like a brand-new one, ON CONFLICT DO NOTHING never finds a prior conflict, and the employee is paid twice.
  • Retrying on a fixed interval with no jitter. When an upstream outage recovers, every stalled consumer wakes on the same clock tick and retries in lockstep, reproducing the outage against the payroll ledger the instant it comes back online — a self-inflicted retry storm.

The fix is a bounded state machine, not an unbounded loop: classify first, back off with jitter inside a hard budget, and dead-letter deterministically the moment the budget is exhausted or the failure is permanent. Backoff for attempt nn (zero-indexed) uses full jitter over an exponential ceiling capped at TcapT_{\text{cap}}:

Tn=Uniform(0, min(Tcap, b2n))T_n = \text{Uniform}\bigl(0,\ \min(T_{\text{cap}},\ b \cdot 2^{n})\bigr)

where bb is the base delay and TcapT_{\text{cap}} bounds runaway growth so attempt 20 does not sleep for a day. The uniform draw — not a fixed midpoint, not a fixed multiplier — is what desynchronizes competing consumers; a jittered ceiling without the uniform draw is still a thundering herd waiting to happen.

Retry, dead-letter, and re-drive state machine for a payroll message One payroll message enters an Attempt state. A success terminates the flow. A failure is classified as transient or permanent. Transient failures retry with exponential backoff and full jitter while the attempt counter stays under the retry budget; exhausting the budget, or any permanent failure, routes the message to a dead-letter queue tagged with a reason code. A manual re-drive after the root cause is fixed resets the attempt counter and returns the message to Attempt. Attempt disburse_idempotent() Result? success Success commit & ack — terminal failure Transient or permanent? transient n < budget? yes · n+=1 Backoff + jitter sleep = Uniform(0, min(cap, b·2ⁿ)) retry exhausted permanent Dead-letter queue reason_code + full envelope context Manual re-drive after fix — resets attempt=0 re-drive
Every transient failure loops back through bounded, jittered backoff; every permanent or budget-exhausted failure lands in the dead-letter queue until a human re-drives it.

Prerequisites & Data Requirements

The retry layer operates on a message envelope, not the raw payroll record — the envelope carries the retry and dead-letter metadata that the record itself has no business knowing about.

Field Type Precondition
message_id str (UUID) Assigned by the queue transport; identifies the delivery, not the payroll fact — never used as the idempotency key.
idempotency_key str (SHA-256 hex) Deterministic hash of (employee_id, pay_period_start, source_run_id); identical on every re-delivery of the same fact.
employee_id str Matches the canonical format enforced at ingestion; part of the natural key.
pay_period_start date (ISO) The statutory period the disbursement applies to; part of the natural key — never the retry’s wall-clock time.
source_run_id str The originating batch run identifier; part of the natural key, so a legitimate re-run is distinguishable from a duplicate delivery of the same run.
gross_pay Decimal Parsed as Decimal at the ingestion boundary per Enforcing Decimal Precision Across Payroll Fields; floats are rejected upstream, not here.
attempt int Zero-indexed counter; incremented only on a transient-failure retry, reset to 0 on a manual re-drive.
max_attempts int The retry budget — a fixed ceiling (e.g. 6) below which the message may still be retried.
reason_code str | None Populated only when the message dead-letters; names exactly which check failed.
status str One of pending, retrying, dead_lettered, posted.
next_retry_at datetime | None Wall-clock time computed as now + backoff; used only for scheduling, never folded into the idempotency key.

Two preconditions hold outside the function signatures below:

  1. The idempotency key is computed once, at ingestion, and carried through every retry. It is never recomputed at retry time — recomputing it against the current record state, rather than the immutable natural key, reintroduces exactly the non-determinism the key exists to eliminate.
  2. Classification happens before the budget is consulted. An exception that is actually permanent must never consume retry budget it doesn’t need; misclassifying it as transient only delays the same guaranteed failure while starving the budget meant for genuinely recoverable errors.

Step-by-Step Implementation

Step 1 — Model the envelope and the failure taxonomy. A two-value enum keeps classification exhaustive and auditable, and the envelope carries every field the retry state machine needs without touching the payroll record itself.

from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional
import hashlib
import logging

logger = logging.getLogger("payroll.retry")


class FailureClass(str, Enum):
    TRANSIENT = "transient"
    PERMANENT = "permanent"


@dataclass
class RetryEnvelope:
    message_id: str
    idempotency_key: str
    employee_id: str
    pay_period_start: str  # ISO date, part of the natural key
    source_run_id: str
    gross_pay: Decimal
    attempt: int = 0
    max_attempts: int = 6
    reason_code: Optional[str] = None
    status: str = "pending"


def derive_idempotency_key(
    employee_id: str, pay_period_start: str, source_run_id: str
) -> str:
    """Deterministic key from the natural key. Never time.time() or uuid4()."""
    natural_key = f"{employee_id}|{pay_period_start}|{source_run_id}"
    return hashlib.sha256(natural_key.encode("utf-8")).hexdigest()


# derive_idempotency_key("E-44817", "2026-07-06", "run-8831")
# -> same 64-char hex string every time, regardless of attempt count

Step 2 — Classify the failure before touching the budget. Route by exception type first, then by an explicit reason_code supplied by the caller, and fail closed: an exception type the classifier has never seen is treated as permanent rather than silently retried. A message that crashes deserialization itself — a poison message — never reaches this function at all; wrap the deserialization step in its own try/except and dead-letter it immediately with reason_code="poison_message", since retrying a payload the consumer cannot even parse only reproduces the crash.

TRANSIENT_EXCEPTIONS = (TimeoutError, ConnectionError)
PERMANENT_REASON_CODES = {
    "schema_validation_failed",
    "duplicate_natural_key",
    "statutory_cap_breach",
    "poison_message",
}


def classify_failure(exc: Exception, reason_code: str) -> FailureClass:
    """Route by exception type first, then by explicit reason_code."""
    if reason_code in PERMANENT_REASON_CODES:
        return FailureClass.PERMANENT
    if isinstance(exc, TRANSIENT_EXCEPTIONS):
        return FailureClass.TRANSIENT
    return FailureClass.PERMANENT  # unknown exceptions fail closed, not open


# classify_failure(TimeoutError(), "upstream_timeout") -> FailureClass.TRANSIENT
# classify_failure(ValueError(), "schema_validation_failed") -> FailureClass.PERMANENT

Step 3 — Compute bounded backoff with full jitter. The delay is a uniform draw over [0, ceiling], not the ceiling itself — the draw, not the exponential growth alone, is what desynchronizes competing consumers after a shared outage.

import random
from decimal import Decimal

BASE_SECONDS = Decimal("2")
CAP_SECONDS = Decimal("120")


def compute_backoff_seconds(attempt: int) -> Decimal:
    """Full-jitter bounded exponential backoff: Uniform(0, min(cap, base * 2**n))."""
    ceiling = min(CAP_SECONDS, BASE_SECONDS * (Decimal(2) ** attempt))
    jitter = Decimal(str(random.uniform(0, float(ceiling))))
    return jitter.quantize(Decimal("0.01"))


# compute_backoff_seconds(0) -> some Decimal in [0.00, 2.00]
# compute_backoff_seconds(6) -> some Decimal in [0.00, 120.00], capped by CAP_SECONDS

Step 4 — Enforce the retry budget and route to the dead-letter queue. A permanent failure dead-letters on attempt 0, spending no budget at all; a transient failure only dead-letters once attempt >= max_attempts.

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional, Tuple


@dataclass
class DeadLetter:
    envelope: RetryEnvelope
    reason_code: str
    failure_class: FailureClass
    last_error: str
    dead_lettered_at: datetime


def route_or_retry(
    envelope: RetryEnvelope, exc: Exception, reason_code: str
) -> Tuple[str, Optional[Decimal]]:
    """Decide retry vs. dead-letter. Returns (action, backoff_seconds)."""
    failure = classify_failure(exc, reason_code)

    if failure is FailureClass.PERMANENT:
        logger.warning(
            "event=dead_letter emp=%s key=%s reason=%s attempt=%s",
            envelope.employee_id, envelope.idempotency_key, reason_code, envelope.attempt,
        )
        return "dead_letter", None

    if envelope.attempt >= envelope.max_attempts:
        logger.warning(
            "event=dead_letter emp=%s key=%s reason=%s attempt=%s budget=%s",
            envelope.employee_id, envelope.idempotency_key,
            "retry_budget_exhausted", envelope.attempt, envelope.max_attempts,
        )
        return "dead_letter", None

    backoff = compute_backoff_seconds(envelope.attempt)
    envelope.attempt += 1
    logger.info(
        "event=retry_scheduled emp=%s key=%s attempt=%s backoff_s=%s",
        envelope.employee_id, envelope.idempotency_key, envelope.attempt, backoff,
    )
    return "retry", backoff


# route_or_retry(envelope, TimeoutError(), "upstream_timeout")
# -> ("retry", Decimal("1.34"))  when envelope.attempt < envelope.max_attempts
# -> ("dead_letter", None)       once envelope.attempt >= envelope.max_attempts

Step 5 — Disburse idempotently, then support a manual re-drive. The upsert is the exactly-once boundary on top of an at-least-once substrate: ON CONFLICT DO NOTHING keyed on idempotency_key makes a repeated delivery a safe no-op instead of a second paycheck.

async def disburse_idempotent(conn, envelope: RetryEnvelope) -> bool:
    """Exactly-once disbursement on an at-least-once substrate."""
    result = await conn.execute(
        """
        INSERT INTO disbursements
            (idempotency_key, employee_id, pay_period_start, source_run_id,
             gross_pay, posted_at)
        VALUES ($1, $2, $3, $4, $5, now())
        ON CONFLICT (idempotency_key) DO NOTHING
        """,
        envelope.idempotency_key, envelope.employee_id, envelope.pay_period_start,
        envelope.source_run_id, envelope.gross_pay,
    )
    inserted = result.split()[-1] == "1"  # asyncpg-style status tag
    logger.info(
        "event=disburse emp=%s key=%s inserted=%s gross=%s",
        envelope.employee_id, envelope.idempotency_key, inserted, envelope.gross_pay,
    )
    return inserted


async def redrive_dead_letter(conn, dead_letter: DeadLetter) -> bool:
    """Manual re-drive after the root cause is fixed. Resets the retry budget."""
    dead_letter.envelope.attempt = 0
    dead_letter.envelope.status = "pending"
    logger.info(
        "event=redrive emp=%s key=%s prior_reason=%s",
        dead_letter.envelope.employee_id,
        dead_letter.envelope.idempotency_key,
        dead_letter.reason_code,
    )
    return await disburse_idempotent(conn, dead_letter.envelope)


# First delivery:  disburse_idempotent(conn, envelope) -> True  (row inserted)
# Re-delivery with the same idempotency_key: -> False (ON CONFLICT DO NOTHING fired)

Verification

Confirm the state machine’s correctness with the boundary cases specific to retry and dead-letter behavior, not just happy-path disbursement:

  1. Idempotency replay produces no double post. Call disburse_idempotent twice with an identical envelope.idempotency_key. Assert the first call returns True, the second returns False, and the disbursements row count for that key is exactly 1.
  2. Budget exhaustion routes to the dead-letter queue. Set max_attempts=3 and feed three consecutive TimeoutError failures through route_or_retry. Assert the first three calls return "retry" and the fourth returns ("dead_letter", None) with reason_code == "retry_budget_exhausted".
  3. Jitter stays within its bounds and is not deterministic. Call compute_backoff_seconds(5) 1,000 times. Assert every result lies in [Decimal("0.00"), min(CAP_SECONDS, BASE_SECONDS * 2**5)], and assert the returned values are not all equal — a constant result across calls means the uniform draw was dropped somewhere and the “jitter” is a fixed delay in disguise.
  4. Permanent failures short-circuit the budget. Call route_or_retry with reason_code="schema_validation_failed" on an envelope where attempt == 0. Assert it dead-letters immediately without incrementing attempt — a permanent failure must never be retried even once.
  5. Re-drive resets the budget and remains idempotent. After redrive_dead_letter, assert envelope.attempt == 0. If the underlying row already posted through a separate path before the re-drive ran, assert disburse_idempotent still returns False rather than raising — a re-drive of an already-settled disbursement is a safe no-op, not an error.

Failure Modes

  • Idempotency key derived from time or a random UUID. Root cause: the key is built from datetime.now(), time.time(), or uuid4() instead of the natural key, so every retry attempt generates a distinct key by construction. Remediation: derive the key purely from (employee_id, pay_period_start, source_run_id) as in derive_idempotency_key, compute it once at ingestion, and carry it unchanged through every retry and re-drive.
  • Unbounded retry budget on a misclassified permanent failure. Root cause: the classifier treats an unrecognized exception as transient by default (“fail open”), so a structurally broken record retries indefinitely and never reaches the dead-letter queue where a human would see it. Remediation: fail closed — unknown exception types classify as PERMANENT, as in classify_failure above — and enforce a hard max_attempts ceiling as a second, independent backstop against any classifier gap.
  • Retry storms from unjittered, synchronized backoff. Root cause: every consumer computes the same fixed delay for a given attempt number, so a shared outage produces a shared recovery-time retry spike that re-triggers the outage against the payroll ledger. Remediation: apply full jitter — a uniform draw over [0, ceiling], not the ceiling itself — and keep the concurrent-write ceiling at the same semaphore bound used for async batch processing’s fetches and upserts, so a retry wave still cannot exceed the connection-pool limit.

Frequently Asked Questions

Why hash the natural key instead of storing employee_id and pay_period_start directly as the idempotency key?

A SHA-256 hash produces a fixed-length, uniformly distributed key regardless of how long employee_id or source_run_id happen to be, which keeps the ledger’s unique index compact and evenly distributed for lookups. It also avoids embedding a readable employee identifier directly into a key that may be logged, queued, or forwarded to a message broker outside the payroll system’s own access controls. The hash is still fully deterministic — the same three natural-key fields always produce the same 64-character hex string.

How is a retry budget different from a circuit breaker?

A retry budget bounds how many times one message may be retried before it dead-letters; it is local to a single payroll record. A circuit breaker bounds how many attempts the whole system makes against a failing dependency over a time window, tripping open to stop issuing new attempts entirely once a failure-rate threshold is crossed. The two are complementary: the breaker protects the downstream system from being hammered, while the budget guarantees an individual message eventually reaches a terminal state — success or dead-letter — instead of retrying forever even while the breaker is closed.

How does a poison message differ from an ordinary permanent failure?

An ordinary permanent failure — a schema violation, a statutory cap breach — is a business-logic rejection that classify_failure can evaluate and route normally. A poison message crashes the consumer before business logic ever runs, typically during deserialization of a corrupted or truncated payload, and a naive consumer that lets that exception propagate will crash-loop on redelivery instead of dead-lettering. Wrap deserialization in its own exception boundary, tag the failure reason_code="poison_message", and route it straight to the dead-letter queue without attempting classification against a payload that could not be parsed in the first place.

Does re-driving a message from the dead-letter queue bypass the idempotency check?

No. A re-drive resets attempt to 0 and clears the retry budget so the message gets a fresh set of attempts, but it still carries the same idempotency_key computed from the original natural key. If the disbursement already posted through another path before the fix landed, ON CONFLICT DO NOTHING still fires on re-drive and disburse_idempotent returns False — the re-drive is safe to run even when the underlying condition turns out to have already resolved itself.