Kafka Reliability: Retries, Dead Letter Topics, and Failure Handling
Kafka keeps events durable, but durable transport does not make event processing reliable by itself. Consumers still need a failure strategy for database timeouts, malformed payloads, external API outages, rate limits, poison messages, and business errors that will never succeed on retry.
A production design should distinguish transient failures from permanent ones, preserve ordering only where it matters, prevent infinite retry loops, make repeated processing safe, and expose enough metrics to understand whether the system is recovering or simply accumulating delayed work.
Table of Contents
- Reliability Starts with Failure Classification
- Why Unlimited Retries Fail
- Retry Strategies
- Dead Letter Topics
- Ordering vs Failure Isolation
- Idempotency Makes Retries Safe
- Practical Payment Webhook Example
- Poison Messages and Invalid Events
- Replay and Manual Recovery
- Production Failure Policy
- What to Monitor
- Conclusion
Reliability Starts with Failure Classification
Not every failed event should be retried the same way. The first design decision is determining whether another attempt can realistically succeed.
| Failure | Typical Classification | Likely Action |
|---|---|---|
| Database connection timeout | Transient | Retry with bounded backoff |
| HTTP 429 from external API | Transient | Retry later with rate-limit-aware delay |
| Malformed JSON | Permanent | Quarantine or dead letter |
| Missing required customer ID | Usually permanent | Reject and investigate producer contract |
| Referenced entity may appear shortly | Potentially transient | Delayed retry with bounded attempts |
A retry is useful only if the next attempt has a meaningful chance of producing a different result. Retrying an event with an impossible schema violation 100,000 times does not improve reliability; it consumes consumer capacity and blocks useful work.
Failure handling should be based on cause, not on the fact that an exception occurred.
Why Unlimited Retries Fail
A simple consumer often retries failed processing in place until it succeeds.
while True:
try:
process(event)
break
except Exception:
time.sleep(1)
This can be acceptable for a very short transient outage, but it becomes dangerous when the event cannot succeed.
One poison message can block an entire partition indefinitely. Every record behind it remains unavailable to that consumer even if those later records are perfectly valid.
Suppose partition 3 contains:
offset 500 → 501 → 502 → 503 → 504
If offset 501 fails forever and processing is strictly sequential, offsets 502 through 504 cannot make progress either.
This creates several secondary problems:
- consumer lag grows continuously;
- retention may eventually delete unprocessed history;
- restarts do not help because the same record fails again;
- autoscaling adds consumers but cannot split one partition;
- operational teams see Kafka lag without immediately seeing the actual bad record.
Retries therefore need explicit limits, delay rules, and an escape path for events that do not recover.
Retry Strategies
There is no single retry model that works for every event. The right strategy depends on failure duration, ordering requirements, external dependency behavior, and how much partition blocking is acceptable.
Immediate Retries
Immediate retries are useful for failures expected to disappear almost instantly, such as a short connection reset.
MAX_ATTEMPTS = 3
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
process_event(event)
break
except TemporaryDatabaseError:
if attempt == MAX_ATTEMPTS:
raise
The advantage is low recovery latency. The risk is creating a retry storm when the dependency is genuinely unhealthy.
If 50 consumers each retry a failed database query several times without delay, the retry traffic can make the database outage worse.
Immediate retries should therefore be few and reserved for very short transient failures.
Backoff Retries
Backoff reduces retry pressure by waiting longer between attempts.
Exponential backoff is common:
import random
import time
def retry_delay(attempt: int) -> float:
base = min(2 ** attempt, 60)
jitter = random.uniform(0, 1)
return base + jitter
Jitter prevents many consumers from retrying at exactly the same time after a shared outage.
This works well for dependencies that usually recover within seconds or minutes.
However, sleeping directly inside a consumer thread for several minutes can block partition progress and interfere with consumer-group timing. Long delays are often better moved outside the main processing path.
Retry Topics
A retry topic lets the consumer move failed work out of the main stream and retry it later.
A common design uses staged topics such as:
payment-eventspayment-events-retry-30spayment-events-retry-5mpayment-events-dlt
After a transient failure, the consumer republishes the event to the appropriate retry topic with metadata describing the attempt.
{
"event_id": "evt_73912",
"retry_count": 2,
"original_topic": "payment-events",
"failure_type": "provider_timeout",
"next_attempt_at": "2026-09-08T21:10:00Z"
}
This avoids holding the main consumer thread for long delays and lets later records continue.
The trade-off is ordering. Once offset 500 is moved to a retry topic while offset 501 continues, the original order can be broken.
Retry topics are therefore best when events are independent or when business ordering can tolerate delayed re-entry.
Dead Letter Topics
A dead letter topic stores events that normal processing cannot complete after the allowed attempts or that are classified as permanently invalid.
Examples include:
- malformed payloads;
- unsupported schema versions;
- missing mandatory identifiers;
- business states that make processing impossible;
- repeated failures after bounded retries.
A dead letter record should contain enough context for diagnosis and replay.
{
"original_topic": "shipment-events",
"original_partition": 7,
"original_offset": 918221,
"event_id": "evt_ship_8831",
"failed_at": "2026-09-08T21:15:14Z",
"consumer": "shipment-indexer",
"failure_type": "invalid_payload",
"error": "tracking_number is required",
"payload": {
"shipment_id": "shp_18291"
}
}
Useful metadata includes the original topic, partition, offset, event ID, consumer name, failure classification, timestamps, and retry count.
The dead letter topic should not become a permanent data graveyard. It needs an operational process for:
- alerting when volume increases;
- inspecting failures;
- fixing bad producers or consumers;
- deciding whether records are discarded or replayed;
- tracking how long unresolved records remain there.
A dead letter topic is a recovery tool, not a substitute for fixing broken data contracts.
Ordering vs Failure Isolation
Retry and dead letter strategies can improve availability by allowing later events to proceed, but this can violate ordering.
Consider an account stream keyed by account_id:
deposit → withdrawal → account.closed
If the withdrawal fails and is moved to a retry topic while account.closed continues, the account may close before the withdrawal is applied.
For such workflows, preserving partition order may be more important than keeping lag low.
Compare that with independent email notifications. If one notification fails because a provider times out, moving it to a retry topic while other emails continue is usually acceptable.
| Workload | Ordering Importance | Typical Retry Direction |
|---|---|---|
| Financial ledger | Very high | Preserve partition sequence |
| Shipment lifecycle | Often high per shipment | Retry carefully using shipment ordering rules |
| Email notifications | Usually low | Retry topic is practical |
| Analytics events | Often low | Failure isolation usually preferred |
The choice should therefore be made per event type or domain invariant rather than applying one global retry pattern to every consumer.
Idempotency Makes Retries Safe
Retries naturally create duplicate attempts. A reliable system should assume the same logical event may be processed more than once.
Suppose an Inventory consumer successfully creates a reservation, but the process crashes before committing its Kafka offset. The event appears again after restart.
A natural business constraint can make the operation idempotent:
INSERT INTO inventory_reservations (
order_id,
product_id,
quantity
)
VALUES (
'ord_92814',
'prd_501',
2
)
ON CONFLICT (order_id, product_id) DO NOTHING;
The duplicate Kafka delivery no longer creates a duplicate reservation.
For external APIs, a stable idempotency key can provide the same protection when supported:
provider.create_shipping_label(
shipment_id="shp_2819",
idempotency_key="label_shp_2819",
)
This is especially important because network failures create ambiguous outcomes. A request may have succeeded remotely even though the consumer saw a timeout.
Delivery semantics and consumer idempotency are covered in Kafka Delivery Semantics: At-Most-Once, At-Least-Once, and Exactly-Once.
Practical Payment Webhook Example
Consider a consumer that reads payment.succeeded and sends a webhook to a merchant.
The external merchant endpoint can:
- respond successfully;
- return a temporary 500 error;
- rate-limit with 429;
- return a permanent 400 error;
- time out after processing the webhook successfully.
A practical failure policy might be:
- Attempt delivery from the main topic.
- Retry a small number of immediate network failures.
- Move persistent transient failures to delayed retry topics.
- Stop after a maximum retry window.
- Move permanently failing deliveries to a dead letter topic.
The webhook request should include a stable event identifier:
headers = {
"X-Event-Id": event["event_id"],
"X-Event-Type": event["event_type"],
}
response = http_client.post(
merchant_url,
json=event,
headers=headers,
timeout=5,
)
A merchant that stores X-Event-Id can make repeated deliveries idempotent.
The consumer should classify the response:
def classify_status(status_code: int) -> str:
if 200 <= status_code < 300:
return "success"
if status_code == 429:
return "retry"
if 500 <= status_code < 600:
return "retry"
if 400 <= status_code < 500:
return "permanent_failure"
return "retry"
A 400 Bad Request usually will not improve after waiting five minutes. A 503 Service Unavailable often will.
Suppose the merchant endpoint times out after successfully accepting the webhook. The consumer cannot know whether the remote side processed it. The safe behavior is usually to retry using the same event ID rather than assuming failure means no side effect occurred.
This is why retry policy and idempotency must be designed together.
Poison Messages and Invalid Events
A poison message is an event that repeatedly fails because of its content rather than temporary infrastructure conditions.
Examples include:
- invalid JSON;
- unsupported enum value;
- schema mismatch;
- missing required business identifier;
- impossible state transition.
These should be detected as early as possible.
For example, validation can run before expensive downstream operations:
from dataclasses import dataclass
@dataclass
class ShipmentEvent:
event_id: str
shipment_id: str
status: str
ALLOWED_STATUSES = {
"created",
"picked_up",
"in_transit",
"delivered",
}
def validate_event(event: ShipmentEvent) -> None:
if not event.event_id:
raise ValueError("event_id is required")
if not event.shipment_id:
raise ValueError("shipment_id is required")
if event.status not in ALLOWED_STATUSES:
raise ValueError("unsupported shipment status")
If validation reveals a permanent contract error, sending the event through several delayed retry topics wastes time and infrastructure.
Permanent contract failures should normally be dead-lettered or quarantined immediately and should trigger investigation of the producer.
A growing dead letter rate after a deployment is often a schema or contract incident, not merely a consumer reliability issue.
Replay and Manual Recovery
Dead-lettered events often need to be replayed after the underlying issue is fixed.
A safe replay process should answer:
- Has the consumer bug been fixed?
- Is the payload now valid?
- Can the business side effect be repeated safely?
- Should the event return to the original topic or a dedicated recovery topic?
- Will replay break ordering assumptions?
Blindly copying every dead letter event back into the main topic can recreate the incident.
A safer recovery process can use a dedicated replay producer that preserves the original event ID while adding recovery metadata:
{
"event_id": "evt_ship_8831",
"replayed_at": "2026-09-08T22:40:00Z",
"replay_reason": "consumer_validation_bug_fixed",
"original_partition": 7,
"original_offset": 918221
}
Keeping the original stable identity lets idempotent consumers recognize whether the event already produced a successful side effect before it entered the dead letter path.
Replay should usually be rate-limited. Reinjecting millions of recovered events at once can overload the consumer, database, or external dependency and cause a second incident.
Production Failure Policy
A strong Kafka consumer should have an explicit failure policy rather than scattered try/except logic.
A practical policy can define:
- Validation failures. Reject immediately and route to quarantine or DLT.
- Short transient failures. Retry a few times locally with jitter.
- Longer transient failures. Move to delayed retry infrastructure when ordering allows it.
- Maximum retry age. Stop retrying after the business usefulness window expires.
- Idempotency. Make every retried business side effect safe to repeat.
- Ordering rule. Define whether later records may pass a failed event.
- Dead letter ownership. Assign a team and recovery process for unresolved events.
- Replay procedure. Reprocess with controlled rate and stable event identity.
The maximum retry window should reflect the event's business value.
A shipment status update may remain useful for hours. A real-time recommendation event may become irrelevant after minutes. A payment event may need to remain recoverable for much longer.
Reliability does not mean retry forever. It means preserving the correct business outcome through failure.
What to Monitor
Failure handling should be observable separately from normal Kafka throughput.
- Retry attempts by error type. Distinguish database timeouts, rate limits, invalid payloads, and other causes.
- Retry success rate. Shows whether retries are actually recovering work.
- Retry age. Measure how long events remain in retry infrastructure.
- Dead letter volume. Alert on increases by topic, event type, and consumer.
- Oldest dead letter age. Detect unresolved events that have been ignored operationally.
- Consumer lag. Main-topic lag can reveal partition blocking during failures.
- Duplicate detections. Measures how often idempotency mechanisms are exercised.
- External dependency latency and error rate. Correlate consumer failures with downstream systems.
- Database saturation. Retry storms often appear together with pool or I/O exhaustion.
- Replay throughput. Ensure recovery traffic does not overwhelm normal processing.
Retry count alone can be misleading. One million successful retries during a ten-second outage may be less concerning than 500 events that have been failing continuously for six hours.
Time-based metrics such as oldest retry age and oldest unresolved dead letter event often provide better operational signal than raw counts.
Conclusion
Reliable Kafka processing requires more than simply retrying exceptions. Failures should be classified into transient and permanent categories, retries should be bounded, long delays should not unnecessarily block partitions, and dead letter topics should have an explicit recovery process.
Retry topics improve failure isolation but can break ordering. Dead letter topics prevent poison messages from blocking progress but should not become permanent storage for ignored errors. Idempotency is what makes repeated attempts safe when failures produce ambiguous outcomes.
The central production principle is: retry only when another attempt can reasonably succeed, preserve ordering only where the business requires it, and make every repeated side effect safe. Reliability comes from controlled recovery behavior, not from infinite persistence.
Comments (0)