Skip to content

AxonOps — AI-Native Control Plane for Open Source Data Platforms

Kafka Consumer Error Handling

Robust error handling ensures consumers can recover from failures without data loss or infinite retry loops. This guide covers error types, handling strategies, and resilience patterns.


CategoryDescriptionExamples
RetriableTemporary failuresNetwork timeout, leader election
Non-retriablePermanent failuresAuthorization, unknown topic
Poison pillBad message dataDeserialization failure, schema mismatch
ProcessingApplication logic failureBusiness rule violation, external service down
Receive messageDeserializesuccessfailureProcesssuccessfailureCommit offsetRetriable?yesnoRetry with backoffMax retries?noyesProcess againSend to DLQSend to DLQLog errorSend to DLQCommit offset

Invalid messages cause deserialization failures:

// Without error handling, bad message crashes consumer
ConsumerRecords<String, Order> records = consumer.poll(Duration.ofMillis(100));
// Throws SerializationException on corrupt message

Kafka provides a wrapper deserializer that captures errors:

Properties props = new Properties();
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class);
props.put(ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS, StringDeserializer.class);
props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class);
while (running) {
ConsumerRecords<String, Order> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, Order> record : records) {
// Check for deserialization failure
if (record.value() == null) {
// With ErrorHandlingDeserializer, failures are captured in headers
byte[] rawValue = getRawValue(record); // Access raw bytes if needed
log.error("Deserialization failed at offset {}: {}",
record.offset(), new String(rawValue));
// Send to dead letter queue
sendToDeadLetterQueue(record);
continue;
}
// Process valid message
processOrder(record.value());
}
consumer.commitSync();
}
public class SafeJsonDeserializer<T> implements Deserializer<T> {
private final ObjectMapper mapper = new ObjectMapper();
private final Class<T> targetType;
@Override
public T deserialize(String topic, byte[] data) {
if (data == null) return null;
try {
return mapper.readValue(data, targetType);
} catch (IOException e) {
// Return null instead of throwing
log.error("Failed to deserialize message: {}", new String(data), e);
return null;
}
}
}

Route failed messages to a dedicated topic for later analysis:

ordersConsumerorders.dlqDLQ DatabaseContains:- Original message- Error details- Retry count- Timestampconsumefailed messagesanalyze
public class DeadLetterQueueHandler {
private final Producer<String, byte[]> dlqProducer;
private final String dlqTopicSuffix = ".dlq";
public void sendToDeadLetterQueue(ConsumerRecord<?, ?> record, Exception error) {
String dlqTopic = record.topic() + dlqTopicSuffix;
ProducerRecord<String, byte[]> dlqRecord = new ProducerRecord<>(
dlqTopic,
record.key() != null ? record.key().toString() : null,
serializeOriginalValue(record)
);
// Add metadata headers
dlqRecord.headers()
.add("dlq.original.topic", record.topic().getBytes())
.add("dlq.original.partition", Integer.toString(record.partition()).getBytes())
.add("dlq.original.offset", Long.toString(record.offset()).getBytes())
.add("dlq.error.message", error.getMessage().getBytes())
.add("dlq.error.class", error.getClass().getName().getBytes())
.add("dlq.timestamp", Instant.now().toString().getBytes());
dlqProducer.send(dlqRecord, (metadata, exception) -> {
if (exception != null) {
log.error("Failed to send to DLQ: {}", record, exception);
}
});
}
}
# Create DLQ topic with appropriate settings
cleanup.policy=delete
retention.ms=2592000000 # 30 days

Simple retry with backoff:

public class RetryingConsumer {
private final int maxRetries = 3;
private final long initialBackoffMs = 100;
private final double backoffMultiplier = 2.0;
public void processWithRetry(ConsumerRecord<String, String> record) {
int attempt = 0;
long backoff = initialBackoffMs;
while (attempt < maxRetries) {
try {
process(record);
return; // Success
} catch (RetriableException e) {
attempt++;
log.warn("Attempt {} failed for offset {}: {}",
attempt, record.offset(), e.getMessage());
if (attempt < maxRetries) {
Thread.sleep(backoff);
backoff = (long) (backoff * backoffMultiplier);
}
}
}
// Max retries exceeded
dlqHandler.sendToDeadLetterQueue(record, lastException);
}
}

Use dedicated retry topics for delayed reprocessing:

ordersConsumerorders.retry.1orders.retry.2orders.dlqconsume1st failure(1 min delay)2nd failure(10 min delay)3rd failure
public class RetryTopicConsumer {
private final Map<String, RetryConfig> retryConfigs = Map.of(
"orders", new RetryConfig(3, List.of(
new RetryLevel("orders.retry.1", Duration.ofMinutes(1)),
new RetryLevel("orders.retry.2", Duration.ofMinutes(10)),
new RetryLevel("orders.retry.3", Duration.ofMinutes(60))
))
);
@KafkaListener(topics = {"orders", "orders.retry.*"})
public void consume(ConsumerRecord<String, String> record) {
try {
process(record);
} catch (RetriableException e) {
int currentRetry = getRetryLevel(record.topic());
RetryConfig config = retryConfigs.get(getBaseTopic(record.topic()));
if (currentRetry < config.maxRetries()) {
RetryLevel nextLevel = config.levels().get(currentRetry);
sendToRetryTopic(record, nextLevel.topic(), nextLevel.delay());
} else {
sendToDeadLetterQueue(record, e);
}
}
}
private void sendToRetryTopic(ConsumerRecord<String, String> record,
String retryTopic, Duration delay) {
ProducerRecord<String, String> retryRecord = new ProducerRecord<>(
retryTopic,
record.key(),
record.value()
);
// Add retry metadata
retryRecord.headers()
.add("retry.original.topic", record.topic().getBytes())
.add("retry.timestamp", Instant.now().toString().getBytes())
.add("retry.delay.ms", Long.toString(delay.toMillis()).getBytes());
// Kafka has no native per-message delay. Delay can be implemented via:
// 1. Topic-level delay (requires Kafka feature or custom implementation)
// 2. Consumer-side delay before processing
// 3. External scheduler
producer.send(retryRecord);
}
}

ErrorHandling
TimeoutExceptionRetry with backoff
NotLeaderOrFollowerExceptionAutomatic retry by client
DisconnectExceptionReconnect and retry
RetriableExceptionApplication-defined retry
try {
process(record);
} catch (TimeoutException | RetriableException e) {
// Retry with backoff
retryWithBackoff(record, e);
} catch (NonRetriableException e) {
// Send to DLQ immediately
sendToDeadLetterQueue(record, e);
}
ErrorHandling
AuthorizationExceptionAlert and fail
InvalidTopicExceptionConfiguration error, fix and restart
SerializationExceptionDLQ (poison pill); without ErrorHandlingDeserializer, this fails poll() before a record is available
RecordTooLargeExceptionDLQ or skip
try {
consumer.commitSync();
} catch (CommitFailedException e) {
// Partition was reassigned during processing
// Work will be reprocessed by new owner
log.warn("Commit failed, partition reassigned: {}", e.getMessage());
} catch (TimeoutException e) {
// Retry commit
retryCommit();
}

Prevent cascading failures when external dependencies are down:

public class CircuitBreakerConsumer {
private final CircuitBreaker circuitBreaker = CircuitBreaker.ofDefaults("external-service");
public void process(ConsumerRecord<String, String> record) {
Supplier<String> decoratedSupplier = CircuitBreaker
.decorateSupplier(circuitBreaker, () -> callExternalService(record));
Try.ofSupplier(decoratedSupplier)
.recover(CallNotPermittedException.class, e -> {
// Circuit is open - pause consumer
pauseConsumer();
scheduleResume();
throw e;
})
.recover(throwable -> {
// Other failures - retry or DLQ
handleFailure(record, throwable);
return null;
});
}
private void pauseConsumer() {
consumer.pause(consumer.assignment());
}
private void scheduleResume() {
scheduler.schedule(() -> {
consumer.resume(consumer.assignment());
}, 30, TimeUnit.SECONDS);
}
}

public class ConsumerMetrics {
private final MeterRegistry registry;
public void recordProcessingSuccess(String topic) {
registry.counter("consumer.processing.success", "topic", topic).increment();
}
public void recordProcessingError(String topic, String errorType) {
registry.counter("consumer.processing.error",
"topic", topic,
"error_type", errorType
).increment();
}
public void recordDlqSent(String topic) {
registry.counter("consumer.dlq.sent", "topic", topic).increment();
}
public void recordRetry(String topic, int attemptNumber) {
registry.counter("consumer.retry",
"topic", topic,
"attempt", String.valueOf(attemptNumber)
).increment();
}
}
MetricAlert Condition
DLQ rate> 1% of messages
Retry rate> 5% of messages
Processing errors> 10 per minute
Circuit breaker openAny open circuit

PracticeRecommendation
Classify errorsDistinguish retriable vs non-retriable
Implement DLQNever lose messages
Limit retriesPrevent infinite loops
Use backoffAvoid overwhelming dependencies
PracticeRecommendation
Track error ratesAlert on anomalies
Log error detailsInclude offset, partition, error type
Monitor DLQProcess DLQ messages regularly
Track retry ratesHigh retry rate indicates problems
PracticeRecommendation
DLQ reprocessingBuild tooling to replay DLQ
Error analysisReview DLQ patterns regularly
Automated recoveryAuto-retry DLQ on fixes

For non-critical messages:

try {
process(record);
} catch (Exception e) {
log.error("Skipping message at offset {}: {}", record.offset(), e.getMessage());
// Continue processing - message is lost
}

For critical processing:

try {
process(record);
} catch (Exception e) {
log.error("Critical error, stopping consumer: {}", e.getMessage());
running.set(false);
throw e;
}

For high-availability requirements:

try {
processWithFullEnrichment(record);
} catch (EnrichmentServiceException e) {
// Fall back to basic processing
processBasic(record);
}