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()oruuid4()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 NOTHINGnever 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 (zero-indexed) uses full jitter over an exponential ceiling capped at :
where is the base delay and 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.
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:
- 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.
- 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:
- Idempotency replay produces no double post. Call
disburse_idempotenttwice with an identicalenvelope.idempotency_key. Assert the first call returnsTrue, the second returnsFalse, and thedisbursementsrow count for that key is exactly1. - Budget exhaustion routes to the dead-letter queue. Set
max_attempts=3and feed three consecutiveTimeoutErrorfailures throughroute_or_retry. Assert the first three calls return"retry"and the fourth returns("dead_letter", None)withreason_code == "retry_budget_exhausted". - 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. - Permanent failures short-circuit the budget. Call
route_or_retrywithreason_code="schema_validation_failed"on an envelope whereattempt == 0. Assert it dead-letters immediately without incrementingattempt— a permanent failure must never be retried even once. - Re-drive resets the budget and remains idempotent. After
redrive_dead_letter, assertenvelope.attempt == 0. If the underlying row already posted through a separate path before the re-drive ran, assertdisburse_idempotentstill returnsFalserather 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(), oruuid4()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 inderive_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 inclassify_failureabove — and enforce a hardmax_attemptsceiling 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.
Related
- Async Batch Processing — the parent topic whose semaphore-gated fetch/upsert model this retry and dead-letter layer plugs into.
- Async Batch Processing for Large Payroll Files — streaming and checkpointing at multi-gigabyte scale, built on the same bounded-concurrency pattern.
- Fallback Routing Strategies — the framework-level dead-letter hierarchy this queue’s reason codes plug into.
- Manual-Review Queue Escalation SLAs — the escalation clock that starts the moment a message lands in the dead-letter queue.