8.1 Error Handling & Resilience
Module Overview
Goal: Turn consumers into production-safe components through comprehensive error handling.
What You'll Learn:
- Deserialization errors and handling
- Poison pill records
- Retry strategies
- Dead Letter Topics (DLTs)
- Seek & replay patterns
- Partial failure handling
- Circuit breakers for consumers
What's NOT Covered Here:
- Delivery semantics (covered in Module 5.3)
- Performance tuning (covered in Module 7.1)
Key Distinction: This is about recovering safely from errors, not guaranteeing semantics.
1. Types of Consumer Errors
Error Categories
┌────────────────────────────────────────────────┐
│ CONSUMER ERROR TYPES │
├────────────────────────────────────────────────┤
│ │
│ 1. Deserialization Errors │
│ └─ Bad data format, schema mismatch │
│ │
│ 2. Processing Errors │
│ ├─ Transient (DB timeout, network) │
│ └─ Permanent (invalid data, logic error) │
│ │
│ 3. Commit Errors │
│ └─ Rebalance, network issues │
│ │
│ 4. Rebalance Errors │
│ └─ Timeout, coordinator issues │
│ │
└────────────────────────────────────────────────┘
2. Deserialization Errors
The Problem
// Consumer configuration
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
// Producer sends binary data (not string!)
producer.send(new ProducerRecord<>("orders", binaryData));
// Consumer tries to deserialize
ConsumerRecords<String, String> records = consumer.poll(...);
// ← SerializationException thrown!
Result: Consumer crashes, can't proceed past bad record.
Solution 1: Error-Handling Deserializer
public class ErrorHandlingDeserializer<T> implements Deserializer<T> {
private Deserializer<T> delegate;
@Override
public T deserialize(String topic, byte[] data) {
try {
return delegate.deserialize(topic, data);
} catch (Exception e) {
logger.error("Deserialization failed for topic {}", topic, e);
// Return null or sentinel value
return null;
}
}
}
// Usage
props.put("value.deserializer", ErrorHandlingDeserializer.class.getName());
props.put("value.deserializer.delegate.class", StringDeserializer.class.getName());
Solution 2: Check for Null
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
if (record.value() == null) {
// Deserialization failed
logger.error("Null value at offset {}", record.offset());
sendToDeadLetterTopic(record);
continue; // Skip this record
}
processRecord(record);
}
Solution 3: Dedicated Error Handler
while (true) {
try {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
processRecord(record);
}
consumer.commitSync();
} catch (SerializationException e) {
// Deserialization failed
logger.error("Deserialization error", e);
// Seek past bad record
consumer.seek(
new TopicPartition(e.getTopic(), e.getPartition()),
e.getOffset() + 1
);
}
}
3. Poison Pill Records
What is a Poison Pill?
A record that consistently causes processing to fail:
- Invalid data format
- Triggers application bug
- Violates business rules
- Causes infinite loop
Problem: Consumer gets stuck on same record forever.
Detection Pattern
Map<TopicPartition, Long> lastProcessedOffset = new HashMap<>();
Map<TopicPartition, Integer> retryCount = new HashMap<>();
int maxRetries = 3;
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
TopicPartition tp = new TopicPartition(record.topic(), record.partition());
// Check if stuck on same offset
if (lastProcessedOffset.get(tp) != null &&
lastProcessedOffset.get(tp) == record.offset()) {
retryCount.put(tp, retryCount.getOrDefault(tp, 0) + 1);
if (retryCount.get(tp) >= maxRetries) {
// Poison pill detected!
logger.error("Poison pill at offset {}", record.offset());
sendToDeadLetterTopic(record);
// Skip past it
consumer.seek(tp, record.offset() + 1);
retryCount.put(tp, 0);
continue;
}
} else {
retryCount.put(tp, 0);
}
try {
processRecord(record);
lastProcessedOffset.put(tp, record.offset());
} catch (Exception e) {
logger.error("Processing failed", e);
// Will retry next poll
}
}
}
4. Retry Strategies
Strategy 1: Immediate Retry
int maxRetries = 3;
for (ConsumerRecord<String, String> record : records) {
boolean success = false;
int attempts = 0;
while (!success && attempts < maxRetries) {
try {
processRecord(record);
success = true;
} catch (TransientException e) {
attempts++;
logger.warn("Retry {} for offset {}", attempts, record.offset());
Thread.sleep(1000); // Wait before retry
}
}
if (!success) {
sendToDeadLetterTopic(record);
}
}
Pros: Simple, immediate feedback
Cons: Blocks poll loop, can cause timeouts
Strategy 2: Retry Queue
Queue<ConsumerRecord<String, String>> retryQueue = new LinkedBlockingQueue<>();
while (true) {
// Process retry queue first
while (!retryQueue.isEmpty()) {
ConsumerRecord<String, String> record = retryQueue.poll();
try {
processRecord(record);
} catch (Exception e) {
if (shouldRetry(record)) {
retryQueue.offer(record); // Re-add to queue
} else {
sendToDeadLetterTopic(record);
}
}
}
// Then process new records
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
try {
processRecord(record);
} catch (Exception e) {
retryQueue.offer(record); // Add to retry queue
}
}
consumer.commitSync();
}
Strategy 3: Retry Topic
// Main topic consumer
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
try {
processRecord(record);
} catch (TransientException e) {
// Send to retry topic with delay
sendToRetryTopic(record, delaySeconds: 60);
} catch (PermanentException e) {
sendToDeadLetterTopic(record);
}
}
consumer.commitSync();
}
// Separate retry topic consumer
// (processes after delay)
5. Dead Letter Topics (DLT)
What is a DLT?
A topic for records that can't be processed after all retry attempts.
orders (main topic)
↓ (processing fails)
orders.retry (retry topic, optional)
↓ (still fails)
orders.dead-letter (DLT)
DLT Implementation
public class DeadLetterProducer {
private final KafkaProducer<String, String> dlqProducer;
private final String dlqTopic;
public DeadLetterProducer(String sourceTopic) {
this.dlqTopic = sourceTopic + ".dead-letter";
// Configure producer
this.dlqProducer = new KafkaProducer<>(props);
}
public void sendToDeadLetter(ConsumerRecord<String, String> record, Exception error) {
ProducerRecord<String, String> dlqRecord =
new ProducerRecord<>(dlqTopic, record.key(), record.value());
// Add headers with error info
dlqRecord.headers().add("original-topic", record.topic().getBytes());
dlqRecord.headers().add("original-partition",
String.valueOf(record.partition()).getBytes());
dlqRecord.headers().add("original-offset",
String.valueOf(record.offset()).getBytes());
dlqRecord.headers().add("error-message", error.getMessage().getBytes());
dlqRecord.headers().add("error-timestamp",
String.valueOf(System.currentTimeMillis()).getBytes());
dlqProducer.send(dlqRecord);
logger.error("Sent to DLT: topic={}, partition={}, offset={}",
record.topic(), record.partition(), record.offset());
}
}
DLT Consumer Pattern
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
try {
processRecord(record);
} catch (Exception e) {
logger.error("Processing failed for offset {}", record.offset(), e);
deadLetterProducer.sendToDeadLetter(record, e);
}
}
consumer.commitSync();
}
6. Seek & Replay Patterns
Seek to Beginning
// Replay entire partition
TopicPartition partition = new TopicPartition("orders", 0);
consumer.assign(Collections.singletonList(partition));
consumer.seekToBeginning(Collections.singletonList(partition));
// Now poll() starts from offset 0
Seek to Specific Offset
// Skip problematic message
TopicPartition partition = new TopicPartition("orders", 0);
consumer.seek(partition, 12345); // Jump to offset 12345
Seek by Timestamp
// Replay from specific time
long timestamp = System.currentTimeMillis() - (24 * 60 * 60 * 1000); // 24 hours ago
Map<TopicPartition, Long> timestampsToSearch = new HashMap<>();
for (TopicPartition partition : consumer.assignment()) {
timestampsToSearch.put(partition, timestamp);
}
Map<TopicPartition, OffsetAndTimestamp> offsets =
consumer.offsetsForTimes(timestampsToSearch);
for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : offsets.entrySet()) {
if (entry.getValue() != null) {
consumer.seek(entry.getKey(), entry.getValue().offset());
}
}
7. Partial Failure Handling
Problem: Batch Processing Failure
// ❌ BAD: All-or-nothing
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
processRecord(record); // If one fails, all fail
}
consumer.commitSync(); // Never reached if error thrown
Solution: Per-Record Error Handling
// ✅ GOOD: Handle each record independently
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
Map<TopicPartition, OffsetAndMetadata> offsetsToCommit = new HashMap<>();
for (TopicPartition partition : records.partitions()) {
List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
for (ConsumerRecord<String, String> record : partitionRecords) {
try {
processRecord(record);
// Track successful offset
offsetsToCommit.put(
partition,
new OffsetAndMetadata(record.offset() + 1)
);
} catch (Exception e) {
logger.error("Failed at offset {}", record.offset(), e);
sendToDeadLetterTopic(record);
// Still commit up to this point
offsetsToCommit.put(
partition,
new OffsetAndMetadata(record.offset() + 1)
);
}
}
}
consumer.commitSync(offsetsToCommit);
8. Circuit Breaker Pattern
Implementation
public class CircuitBreaker {
private int failureCount = 0;
private int failureThreshold = 5;
private long resetTimeout = 60000; // 1 minute
private long lastFailureTime = 0;
private State state = State.CLOSED;
enum State { CLOSED, OPEN, HALF_OPEN }
public boolean allowRequest() {
if (state == State.OPEN) {
if (System.currentTimeMillis() - lastFailureTime > resetTimeout) {
state = State.HALF_OPEN;
return true;
}
return false; // Circuit open, reject request
}
return true;
}
public void recordSuccess() {
failureCount = 0;
state = State.CLOSED;
}
public void recordFailure() {
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (failureCount >= failureThreshold) {
state = State.OPEN;
logger.error("Circuit breaker opened after {} failures", failureCount);
}
}
}
// Usage
CircuitBreaker circuitBreaker = new CircuitBreaker();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
if (!circuitBreaker.allowRequest()) {
logger.warn("Circuit breaker open, skipping processing");
sendToRetryTopic(record);
continue;
}
try {
processRecord(record);
circuitBreaker.recordSuccess();
} catch (Exception e) {
circuitBreaker.recordFailure();
sendToDeadLetterTopic(record);
}
}
consumer.commitSync();
}
9. Graceful Degradation
Fallback Pattern
for (ConsumerRecord<String, String> record : records) {
try {
// Primary processing
processPrimary(record);
} catch (DatabaseException e) {
try {
// Fallback: cache
processWithCache(record);
} catch (CacheException e2) {
// Last resort: queue for later
sendToRetryQueue(record);
}
}
}
10. Error Handling Checklist
Production-Ready Error Handling
while (running) {
try {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
try {
// 1. Validate
if (!isValid(record)) {
logger.warn("Invalid record at offset {}", record.offset());
sendToDeadLetterTopic(record);
continue;
}
// 2. Process with retry
processWithRetry(record, maxRetries: 3);
} catch (PoisonPillException e) {
// 3. Handle poison pills
logger.error("Poison pill detected", e);
sendToDeadLetterTopic(record);
} catch (TransientException e) {
// 4. Handle transient errors
logger.warn("Transient error, will retry", e);
sendToRetryTopic(record);
} catch (Exception e) {
// 5. Catch-all
logger.error("Unexpected error", e);
sendToDeadLetterTopic(record);
}
}
// 6. Commit after batch
consumer.commitSync();
} catch (WakeupException e) {
// 7. Graceful shutdown
break;
} catch (Exception e) {
// 8. Fatal error
logger.error("Fatal error in consumer loop", e);
break;
}
}
10. Framework Integration: Spring Kafka
Overview
If you're using Spring Kafka (or similar frameworks), many error-handling patterns are built-in. This section shows framework-specific features that simplify error handling.
Note: This is optional — the patterns above work with any Kafka client. Frameworks provide convenience but aren't required.
Spring Kafka ErrorHandlingDeserializer
Spring Kafka (2.3+) provides a built-in error-handling deserializer that wraps any deserializer and gracefully handles failures.
Configuration
# application.properties
spring.kafka.consumer.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=my-group
# Use ErrorHandlingDeserializer as wrapper
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
# Specify the actual deserializer as delegate
spring.kafka.consumer.properties.spring.deserializer.value.delegate.class=org.apache.kafka.common.serialization.StringDeserializer
# Optional: Custom error handler
spring.kafka.consumer.properties.spring.deserializer.value.deserializer.exception.handler=myDeserializationErrorHandler
Java Configuration
@Configuration
public class KafkaConsumerConfig {
@Bean
public ConsumerFactory<String, String> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "my-group");
// Use ErrorHandlingDeserializer
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
ErrorHandlingDeserializer.class);
// Delegate to actual deserializer
props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS,
JsonDeserializer.class);
// Optional: trusted packages for JSON deserialization
props.put(JsonDeserializer.TRUSTED_PACKAGES, "com.mycompany.models");
return new DefaultKafkaConsumerFactory<>(props);
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String>
kafkaListenerContainerFactory(ConsumerFactory<String, String> consumerFactory) {
ConcurrentKafkaListenerContainerFactory<String, String> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory);
return factory;
}
}
How It Works
Message arrives
↓
ErrorHandlingDeserializer (wrapper)
↓
Try to deserialize using delegate
↓
Success?
├─ YES → Return deserialized object
└─ NO → Return null (or DeserializationException)
Consumer can check for null and handle
In your consumer:
@KafkaListener(topics = "orders")
public void listen(ConsumerRecord<String, Order> record) {
if (record.value() == null) {
// Deserialization failed!
logger.error("Deserialization error at offset {}", record.offset());
// Access exception details from headers
DeserializationException ex =
(DeserializationException) record.headers()
.lastHeader(ErrorHandlingDeserializer.VALUE_DESERIALIZER_EXCEPTION_HEADER)
.value();
// Send to DLT or handle
sendToDeadLetterTopic(record);
return;
}
// Process normally
processOrder(record.value());
}
Spring Kafka DefaultErrorHandler
Spring Kafka provides a DefaultErrorHandler for handling processing exceptions (not just deserialization).
Configuration
@Configuration
public class KafkaErrorHandlerConfig {
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String>
kafkaListenerContainerFactory(ConsumerFactory<String, String> consumerFactory) {
ConcurrentKafkaListenerContainerFactory<String, String> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory);
// Configure error handler with retry
DefaultErrorHandler errorHandler = new DefaultErrorHandler(
new DeadLetterPublishingRecoverer(kafkaTemplate(),
(record, ex) -> {
// Custom DLT topic naming
return new TopicPartition(record.topic() + ".DLT", -1);
}),
new FixedBackOff(1000L, 3) // 3 retries with 1 second between
);
// Don't retry certain exceptions
errorHandler.addNotRetryableExceptions(
NullPointerException.class,
IllegalArgumentException.class
);
factory.setCommonErrorHandler(errorHandler);
return factory;
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate(
ProducerFactory<String, String> producerFactory) {
return new KafkaTemplate<>(producerFactory);
}
}
Usage in Consumer
@Service
public class OrderConsumer {
@KafkaListener(topics = "orders", groupId = "order-service")
public void processOrder(ConsumerRecord<String, Order> record) {
// If this throws an exception, DefaultErrorHandler handles it:
// 1. Retries 3 times with 1 second backoff
// 2. If still fails, sends to DLT
// 3. Commits offset
if (record.value().getAmount() < 0) {
throw new IllegalArgumentException("Negative amount"); // Not retryable
}
// May throw transient exception (will retry)
orderService.save(record.value());
}
}
What Spring Kafka does for you:
- ✅ Automatic retries with configurable backoff
- ✅ Automatic DLT publishing on failure
- ✅ Exception classification (retryable vs non-retryable)
- ✅ Offset management (commits after success or DLT)
- ✅ Headers added to DLT messages (original topic, offset, exception)
Spring Kafka Retry Topics
For more advanced retry scenarios, Spring Kafka supports retry topics with progressive delays.
Configuration
@Configuration
@EnableKafka
@EnableKafkaRetryTopic
public class KafkaRetryConfig {
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String>
kafkaListenerContainerFactory(ConsumerFactory<String, String> consumerFactory) {
ConcurrentKafkaListenerContainerFactory<String, String> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory);
return factory;
}
}
@Service
public class OrderConsumer {
@RetryableTopic(
attempts = "4", // 1 original + 3 retries
backoff = @Backoff(delay = 1000, multiplier = 2.0), // 1s, 2s, 4s
autoCreateTopics = "true",
include = {TransientException.class},
exclude = {PermanentException.class}
)
@KafkaListener(topics = "orders")
public void processOrder(Order order) {
// Process order
// If TransientException thrown, retries on:
// - orders-retry-1000 (after 1 second)
// - orders-retry-2000 (after 2 seconds)
// - orders-retry-4000 (after 4 seconds)
// If still fails, sent to orders-dlt
orderService.save(order);
}
@DltHandler
public void handleDlt(Order order,
@Header(KafkaHeaders.ORIGINAL_TOPIC) String originalTopic,
@Header(KafkaHeaders.EXCEPTION_MESSAGE) String exceptionMessage) {
// Handle DLT records
logger.error("Order sent to DLT from {}: {}", originalTopic, exceptionMessage);
alertService.notifyFailure(order, exceptionMessage);
}
}
What it creates:
orders (main topic)
↓ (failure)
orders-retry-1000 (retry after 1 second)
↓ (failure)
orders-retry-2000 (retry after 2 seconds)
↓ (failure)
orders-retry-4000 (retry after 4 seconds)
↓ (failure)
orders-dlt (dead letter topic)
Spring Kafka vs Vanilla Kafka
| Feature | Vanilla Kafka | Spring Kafka |
|---|---|---|
| Error-handling deserializer | Manual implementation | Built-in ErrorHandlingDeserializer |
| Retry logic | Manual retry loop | Built-in DefaultErrorHandler |
| Retry topics | Manual topic creation + routing | @RetryableTopic annotation |
| DLT publishing | Manual producer + headers | Automatic DeadLetterPublishingRecoverer |
| Exception classification | Manual try-catch | addNotRetryableExceptions() |
| Backoff strategies | Manual Thread.sleep() |
FixedBackOff, ExponentialBackOff |
When to use Spring Kafka:
- ✅ Already using Spring Boot/Framework
- ✅ Want declarative configuration (
@KafkaListener) - ✅ Need retry topics with minimal code
- ✅ Prefer convention over configuration
When to use Vanilla Kafka:
- ✅ Not using Spring
- ✅ Need full control over consumer loop
- ✅ Want minimal dependencies
- ✅ Building custom framework
Other Frameworks
Kafka Streams
Kafka Streams has built-in error handling:
StreamsConfig config = new StreamsConfig(props);
config.put(StreamsConfig.DEFAULT_DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG,
LogAndContinueExceptionHandler.class);
KafkaStreams streams = new KafkaStreams(topology, config);
MicroProfile Reactive Messaging
@Incoming("orders")
@Outgoing("processed-orders")
@Retry(maxRetries = 3, delay = 1000)
@Fallback(fallbackMethod = "fallbackProcessOrder")
public Order processOrder(Order order) {
// Process with automatic retry
return orderService.process(order);
}
public Order fallbackProcessOrder(Order order) {
// Fallback if all retries fail
return orderService.processBestEffort(order);
}
Framework Decision Guide
Are you using Spring Boot?
├─ YES → Use Spring Kafka
│ ├─ @KafkaListener for consumers
│ ├─ ErrorHandlingDeserializer for deserialization errors
│ ├─ DefaultErrorHandler for processing errors
│ └─ @RetryableTopic for retry topics
│
└─ NO → Are you building event streaming?
├─ YES → Use Kafka Streams
│ └─ Built-in error handlers
│
└─ NO → Use vanilla Kafka client
└─ Implement patterns from sections 1-9
11. Best Practices Summary
Always handle deserialization errors
- Use error-handling deserializer (custom or framework)
- Check for null values in consumer loop
- Send bad records to DLT
Classify exceptions
- Transient: Retry (DB timeout, network issue)
- Permanent: Don't retry (invalid data, logic error)
Use Dead Letter Topics
- For records that fail after all retries
- Include error metadata in headers
- Monitor and alert on DLT activity
Implement idempotent processing
- Retry logic means duplicates are possible
- Use deduplication or idempotent operations
Monitor error rates
- Track deserialization errors
- Track processing failures
- Alert on DLT throughput spikes
Test failure scenarios
- Inject bad data in tests
- Test retry exhaustion
- Verify DLT routing
Summary
You learned:
- ✅ Handle deserialization errors with error-handling deserializer
- ✅ Detect and handle poison pill records
- ✅ Implement retry strategies (immediate, queue, topic)
- ✅ Use Dead Letter Topics for unprocessable records
- ✅ Seek to replay or skip problematic offsets
- ✅ Handle partial failures in batches
- ✅ Use circuit breakers to prevent cascading failures
- ✅ Implement graceful degradation with fallbacks
Next up: Monitoring, Lag & Observability (Module 9.1) — Learn how to monitor consumers in production.