6.1 Error Handling & Failure Scenarios
Module Overview
Goal: Turn theory into operational reality. Learn to handle producer errors effectively.
What You'll Learn:
- Retriable vs non-retriable errors
- Serialization errors and how to handle them
- Authorization and metadata errors
- Timeout scenarios and recovery
- Callback-based error handling patterns
- When to fail fast vs retry forever
- Production error handling strategies
- Monitoring and alerting for failures
What's NOT Covered Here:
- How to prevent failures (covered in reliability modules)
- Performance optimization (covered in Module 8)
- Internal mechanics (covered in Module 7.1)
Key Distinction: This module is about reacting to failure, not preventing it.
1. Error Categories
Three Types of Errors
┌─────────────────────────────────────────────────┐
│ PRODUCER ERROR TYPES │
├─────────────────────────────────────────────────┤
│ │
│ 1. RETRIABLE ERRORS │
│ → Producer can retry automatically │
│ → Transient failures │
│ → Example: Network glitch │
│ │
│ 2. NON-RETRIABLE ERRORS │
│ → Cannot be fixed by retrying │
│ → Permanent failures │
│ → Example: Invalid topic │
│ │
│ 3. FATAL ERRORS │
│ → Producer cannot continue │
│ → Must close and recreate │
│ → Example: ProducerFencedException │
│ │
└─────────────────────────────────────────────────┘
2. Retriable Errors
Common Retriable Errors
| Error | Description | Cause | Producer Action |
|---|---|---|---|
NOT_LEADER_FOR_PARTITION |
Broker is not leader | Leader election in progress | Auto-retry |
NOT_ENOUGH_REPLICAS |
ISR count below min.insync.replicas |
Broker down | Auto-retry |
REQUEST_TIMED_OUT |
Broker didn't respond in time | Slow broker or network | Auto-retry |
NETWORK_EXCEPTION |
Network failure | Network glitch | Auto-retry |
BROKER_NOT_AVAILABLE |
Broker unreachable | Broker down or restarting | Auto-retry |
How Producer Handles Retriable Errors
// Configuration
props.put("retries", Integer.MAX_VALUE); // Retry indefinitely
props.put("retry.backoff.ms", 100); // Wait 100ms between retries
props.put("delivery.timeout.ms", 120000); // Give up after 2 minutes
Behavior:
Time Action
─────┼────────────────────────────────
0ms send(record)
10ms → Broker: NOT_LEADER_FOR_PARTITION
110ms Retry 1 (after backoff)
210ms Retry 2
310ms Retry 3
410ms Success! ✅
Producer automatically retries — No application code needed.
Example: Handling Retriable Errors
producer.send(record, (metadata, exception) -> {
if (exception == null) {
// Success (may have been retried internally)
logger.info("Sent successfully: offset={}", metadata.offset());
} else {
// All retries exhausted or non-retriable error
if (exception instanceof RetriableException) {
logger.error("Failed after retries", exception);
// Alert operations team
} else {
logger.error("Non-retriable error", exception);
// Handle differently (see next section)
}
}
});
3. Non-Retriable Errors
Common Non-Retriable Errors
| Error | Description | Cause | Action Required |
|---|---|---|---|
INVALID_TOPIC_EXCEPTION |
Topic doesn't exist | Topic not created | Create topic |
RECORD_TOO_LARGE |
Message exceeds limit | Message too big | Reduce size or increase limit |
CORRUPT_MESSAGE |
Serialization failed | Serializer error | Fix serializer |
INVALID_CONFIG |
Invalid producer config | Configuration error | Fix config |
UNSUPPORTED_VERSION |
API version mismatch | Client/broker incompatible | Upgrade client or broker |
AUTHORIZATION_FAILED |
No permission | ACL denied | Grant permission |
INVALID_REQUIRED_ACKS |
Invalid acks value |
Configuration error | Fix acks config |
Handling Non-Retriable Errors
producer.send(record, (metadata, exception) -> {
if (exception != null) {
if (exception instanceof RecordTooLargeException) {
logger.error("Record too large: size={}", record.value().length);
// Option 1: Split record
// Option 2: Store large data elsewhere, send reference
// Option 3: Increase max.message.bytes
} else if (exception instanceof UnknownTopicOrPartitionException) {
logger.error("Topic doesn't exist: {}", record.topic());
// Create topic (if auto-create disabled)
} else if (exception instanceof AuthorizationException) {
logger.error("Permission denied for topic: {}", record.topic());
// Grant ACL permissions
} else if (exception instanceof SerializationException) {
logger.error("Serialization failed for record: {}", record);
// Fix data or serializer
} else {
logger.error("Unexpected error", exception);
// Generic handling
}
}
});
4. Fatal Errors
Fatal Errors (Must Close Producer)
| Error | Cause | Recovery |
|---|---|---|
ProducerFencedException |
Another producer with same transactional.id |
Close producer, restart application |
OutOfOrderSequenceException |
Sequence number corruption | Close producer, recreate |
AuthorizationException (in transactions) |
Permission denied during transaction | Close producer, fix permissions |
Handling Fatal Errors
try {
producer.beginTransaction();
producer.send(record1);
producer.send(record2);
producer.commitTransaction();
} catch (ProducerFencedException e) {
// Fatal: Another producer fenced this one
logger.error("Producer fenced — closing", e);
producer.close();
// Exit application or recreate producer
System.exit(1);
} catch (OutOfOrderSequenceException e) {
// Fatal: Sequence corruption
logger.error("Sequence error — closing", e);
producer.close();
System.exit(1);
} catch (AuthorizationException e) {
// Fatal in transactions
logger.error("Authorization failed — closing", e);
producer.close();
System.exit(1);
} catch (KafkaException e) {
// Retriable
producer.abortTransaction();
// Can retry
}
5. Serialization Errors
When Serialization Fails
// Custom serializer
public class UserSerializer implements Serializer<User> {
@Override
public byte[] serialize(String topic, User user) {
if (user == null) {
return null;
}
if (user.getEmail() == null) {
throw new SerializationException("Email cannot be null");
}
// ... serialization logic
}
}
When serializer throws exception:
User user = new User(123, "Alice", null); // No email
producer.send(new ProducerRecord<>("users", user), (metadata, exception) -> {
if (exception != null) {
// exception is SerializationException
logger.error("Serialization failed", exception);
}
});
Important: Serialization happens before sending to broker.
Handling Serialization Errors
Pattern 1: Validate Before Sending
public void sendUser(User user) {
// Validate
if (user.getEmail() == null || user.getEmail().isEmpty()) {
logger.error("Invalid user: missing email");
throw new IllegalArgumentException("Email required");
}
// Send
ProducerRecord<String, User> record =
new ProducerRecord<>("users", String.valueOf(user.getId()), user);
producer.send(record);
}
✅ Best practice: Validate before sending.
Pattern 2: Catch in Callback
producer.send(record, (metadata, exception) -> {
if (exception instanceof SerializationException) {
logger.error("Failed to serialize: {}", record, exception);
// Log to dead letter queue
sendToDeadLetterQueue(record);
}
});
Pattern 3: Fallback Serializer
public class SafeSerializer implements Serializer<User> {
private final UserSerializer primarySerializer = new UserSerializer();
private final ByteArraySerializer fallbackSerializer = new ByteArraySerializer();
@Override
public byte[] serialize(String topic, User user) {
try {
return primarySerializer.serialize(topic, user);
} catch (Exception e) {
logger.error("Primary serialization failed, using fallback", e);
// Fallback: Store error message as bytes
return ("ERROR: " + e.getMessage()).getBytes();
}
}
}
6. Timeout Scenarios
Timeout Configuration
props.put("delivery.timeout.ms", 120000); // 2 minutes total
props.put("request.timeout.ms", 30000); // 30 seconds per request
props.put("max.block.ms", 60000); // 60 seconds for metadata
Scenario 1: request.timeout.ms
What it controls: How long to wait for broker response per request.
Time Action
─────── ──────────────────────────────────
0ms send(record)
10ms Request sent to broker
30010ms request.timeout.ms expires
→ Retry (if retries enabled)
30110ms Retry sent
30200ms Success ✅
Handling:
props.put("request.timeout.ms", 30000);
props.put("retries", Integer.MAX_VALUE);
// Producer automatically retries — no code needed
Scenario 2: delivery.timeout.ms
What it controls: Maximum time from send() to success or failure.
Time Action
──────── ──────────────────────────────────
0ms send(record)
→ Try, timeout, retry
30s → Try, timeout, retry
60s → Try, timeout, retry
90s → Try, timeout, retry
120000ms delivery.timeout.ms expires
→ TimeoutException in callback
Handling:
producer.send(record, (metadata, exception) -> {
if (exception instanceof TimeoutException) {
logger.error("Delivery timeout after 2 minutes", exception);
// Alert operations team
// Log to dead letter queue
sendToDeadLetterQueue(record);
}
});
Scenario 3: max.block.ms
What it controls: How long send() can block waiting for metadata or buffer space.
// Scenario A: Metadata not available
producer.send(new ProducerRecord<>("new-topic", "key", "value"));
// → Blocks up to max.block.ms waiting for metadata
// → If timeout, throws TimeoutException
// Scenario B: Buffer full
// Producer sending faster than broker can consume
producer.send(record); // Blocks waiting for buffer space
Handling:
try {
producer.send(record);
} catch (TimeoutException e) {
logger.error("Blocked for {} ms", props.get("max.block.ms"), e);
// Possible causes:
// 1. Topic doesn't exist and auto-create disabled
// 2. Broker unreachable
// 3. Buffer full (backpressure)
}
7. Callback-Based Error Handling
Callback Execution Context
⚠️ Important: Callbacks execute in producer's I/O thread.
producer.send(record, (metadata, exception) -> {
// ⚠️ Running in producer I/O thread!
// Keep this fast — don't block
});
Rules:
- ✅ Fast operations (logging, metrics)
- ❌ Slow operations (HTTP calls, database writes)
- ❌ Blocking operations
Pattern 1: Fast Logging
producer.send(record, (metadata, exception) -> {
if (exception == null) {
logger.info("Sent: offset={}", metadata.offset());
} else {
logger.error("Failed to send", exception);
}
});
✅ Good: Fast logging.
Pattern 2: Offload to Thread Pool
private final ExecutorService executor = Executors.newFixedThreadPool(4);
producer.send(record, (metadata, exception) -> {
if (exception != null) {
// Offload slow work
executor.submit(() -> {
sendToDeadLetterQueue(record); // Slow operation
alertOperationsTeam(exception); // Slow operation
});
}
});
✅ Good: Offload slow work.
Pattern 3: Error Aggregation
private final ConcurrentHashMap<String, AtomicInteger> errorCounts = new ConcurrentHashMap<>();
producer.send(record, (metadata, exception) -> {
if (exception != null) {
String errorType = exception.getClass().getSimpleName();
errorCounts.computeIfAbsent(errorType, k -> new AtomicInteger()).incrementAndGet();
}
});
// Separate thread logs every minute
scheduledExecutor.scheduleAtFixedRate(() -> {
errorCounts.forEach((errorType, count) -> {
logger.warn("Error type: {} count: {}", errorType, count.getAndSet(0));
});
}, 1, 1, TimeUnit.MINUTES);
✅ Good: Aggregated error reporting.
8. Fail Fast vs Retry Forever
When to Fail Fast
props.put("retries", 0); // Don't retry
props.put("delivery.timeout.ms", 5000); // Fail after 5 seconds
Use When:
- Ultra-low latency required
- Data loss acceptable (metrics, logs)
- Client-side retry logic preferred
When to Retry Forever
props.put("retries", Integer.MAX_VALUE);
props.put("delivery.timeout.ms", 120000); // 2 minutes
Use When:
- Data loss unacceptable
- Transient failures expected
- Application can tolerate delays
Hybrid Approach
props.put("retries", Integer.MAX_VALUE);
props.put("delivery.timeout.ms", 120000);
producer.send(record, (metadata, exception) -> {
if (exception != null) {
if (isCriticalData(record)) {
// Critical data: Fail entire application
logger.error("Critical data failed — shutting down", exception);
System.exit(1);
} else {
// Non-critical: Log and continue
logger.warn("Non-critical data failed", exception);
metrics.increment("non_critical_failures");
}
}
});
9. Dead Letter Queue Pattern
What is a Dead Letter Queue?
A DLQ is a separate topic for messages that failed to process.
Primary Topic: "orders"
DLQ Topic: "orders-dlq"
Producer sends to "orders"
├─ Success → Stays in "orders"
└─ Failure → Send to "orders-dlq"
Implementing DLQ
public class ProducerWithDLQ {
private final KafkaProducer<String, String> producer;
private final String primaryTopic;
private final String dlqTopic;
public ProducerWithDLQ(KafkaProducer<String, String> producer,
String primaryTopic) {
this.producer = producer;
this.primaryTopic = primaryTopic;
this.dlqTopic = primaryTopic + "-dlq";
}
public void send(String key, String value) {
ProducerRecord<String, String> record =
new ProducerRecord<>(primaryTopic, key, value);
producer.send(record, (metadata, exception) -> {
if (exception != null) {
// Send to DLQ
sendToDLQ(key, value, exception);
}
});
}
private void sendToDLQ(String key, String value, Exception exception) {
// Include error details in DLQ message
String dlqValue = String.format(
"{\"originalValue\":\"%s\",\"error\":\"%s\",\"timestamp\":%d}",
value, exception.getMessage(), System.currentTimeMillis()
);
ProducerRecord<String, String> dlqRecord =
new ProducerRecord<>(dlqTopic, key, dlqValue);
producer.send(dlqRecord, (metadata, dlqException) -> {
if (dlqException != null) {
// DLQ send failed — critical!
logger.error("DLQ send failed", dlqException);
// Fallback: Write to file, alert ops, etc.
}
});
}
}
DLQ Best Practices
✅ Include error metadata:
{
"originalKey": "user-123",
"originalValue": "...",
"originalTopic": "orders",
"errorType": "SerializationException",
"errorMessage": "Invalid JSON",
"timestamp": 1674567890123,
"attemptCount": 5
}
✅ Monitor DLQ size:
// Alert if DLQ accumulates
if (dlqMessageCount > threshold) {
alertOps("DLQ threshold exceeded");
}
✅ Reprocess DLQ:
// Separate job to reprocess DLQ messages
consumer.subscribe(Collections.singletonList("orders-dlq"));
// Attempt to reprocess and send back to primary topic
10. Circuit Breaker Pattern
What is a Circuit Breaker?
Temporarily stop sending if broker is consistently failing.
┌─────────────────────────────────────┐
│ CIRCUIT BREAKER STATES │
├─────────────────────────────────────┤
│ │
│ CLOSED → Sending normally │
│ ↓ │
│ Failures exceed threshold │
│ ↓ │
│ OPEN → Stop sending │
│ ↓ │
│ Wait (cooldown period) │
│ ↓ │
│ HALF-OPEN → Try sending again │
│ ↓ │
│ Success? → CLOSED │
│ Failure? → OPEN │
│ │
└─────────────────────────────────────┘
Implementing Circuit Breaker
public class CircuitBreakerProducer {
private enum State { CLOSED, OPEN, HALF_OPEN }
private State state = State.CLOSED;
private int failureCount = 0;
private long lastFailureTime = 0;
private static final int FAILURE_THRESHOLD = 5;
private static final long COOLDOWN_MS = 60000; // 1 minute
public void send(ProducerRecord<String, String> record) {
if (state == State.OPEN) {
// Check if cooldown expired
if (System.currentTimeMillis() - lastFailureTime > COOLDOWN_MS) {
state = State.HALF_OPEN;
logger.info("Circuit breaker: OPEN → HALF_OPEN");
} else {
logger.warn("Circuit breaker OPEN — rejecting send");
throw new IllegalStateException("Circuit breaker open");
}
}
producer.send(record, (metadata, exception) -> {
if (exception == null) {
// Success
if (state == State.HALF_OPEN) {
state = State.CLOSED;
failureCount = 0;
logger.info("Circuit breaker: HALF_OPEN → CLOSED");
}
} else {
// Failure
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (failureCount >= FAILURE_THRESHOLD && state == State.CLOSED) {
state = State.OPEN;
logger.error("Circuit breaker: CLOSED → OPEN (failures={})", failureCount);
}
}
});
}
}
11. Monitoring and Alerting
Key Metrics to Monitor
| Metric | What to Monitor | Alert Threshold |
|---|---|---|
| Send failures | producer.send.failed |
> 1% |
| Timeout rate | TimeoutException count |
> 0.1% |
| Serialization errors | SerializationException count |
> 0 |
| Retry rate | producer.retries |
Trending up |
| Buffer exhaustion | buffer-exhausted-rate |
> 0 |
| DLQ size | Messages in DLQ topic | > 100 |
Example: Metrics with Micrometer
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
public class MonitoredProducer {
private final KafkaProducer<String, String> producer;
private final Counter successCounter;
private final Counter failureCounter;
public MonitoredProducer(KafkaProducer<String, String> producer,
MeterRegistry registry) {
this.producer = producer;
this.successCounter = registry.counter("kafka.producer.success");
this.failureCounter = registry.counter("kafka.producer.failure");
}
public void send(ProducerRecord<String, String> record) {
producer.send(record, (metadata, exception) -> {
if (exception == null) {
successCounter.increment();
} else {
failureCounter.increment();
registry.counter("kafka.producer.failure",
"error_type", exception.getClass().getSimpleName()).increment();
}
});
}
}
12. Production Error Handling Template
Comprehensive Error Handling
public class ProductionProducer {
private final KafkaProducer<String, String> producer;
private final String dlqTopic;
private final ExecutorService executor;
public void send(ProducerRecord<String, String> record) {
producer.send(record, (metadata, exception) -> {
if (exception == null) {
// Success
handleSuccess(metadata);
} else {
// Failure
handleFailure(record, exception);
}
});
}
private void handleSuccess(RecordMetadata metadata) {
logger.debug("Sent: topic={}, partition={}, offset={}",
metadata.topic(), metadata.partition(), metadata.offset());
metrics.increment("producer.success");
}
private void handleFailure(ProducerRecord<String, String> record, Exception exception) {
logger.error("Send failed: topic={}, key={}",
record.topic(), record.key(), exception);
metrics.increment("producer.failure", "error_type", exception.getClass().getSimpleName());
if (exception instanceof SerializationException) {
// Data problem — send to DLQ immediately
sendToDLQ(record, exception);
} else if (exception instanceof TimeoutException) {
// Timeout after retries — broker issue
alertOps("Producer timeout", exception);
sendToDLQ(record, exception);
} else if (exception instanceof RecordTooLargeException) {
// Record too large — log and DLQ
logger.error("Record too large: {}", record.value().length());
sendToDLQ(record, exception);
} else if (exception instanceof RetriableException) {
// All retries exhausted — likely broker down
alertOps("Retriable error after max retries", exception);
sendToDLQ(record, exception);
} else {
// Unknown error
logger.error("Unknown error", exception);
sendToDLQ(record, exception);
}
}
private void sendToDLQ(ProducerRecord<String, String> record, Exception exception) {
executor.submit(() -> {
try {
String dlqValue = createDLQPayload(record, exception);
ProducerRecord<String, String> dlqRecord =
new ProducerRecord<>(dlqTopic, record.key(), dlqValue);
producer.send(dlqRecord);
} catch (Exception e) {
logger.error("DLQ send failed — CRITICAL", e);
// Last resort: Write to disk
writeToFile(record, exception);
}
});
}
}
Summary
Key Takeaways
✅ Three error categories: Retriable, non-retriable, fatal
✅ Retriable errors: Producer auto-retries (configure with retries, delivery.timeout.ms)
✅ Non-retriable errors: Fix underlying issue (data, config, permissions)
✅ Fatal errors: Close producer and recreate
✅ Serialization errors: Validate before sending
✅ Timeouts: Configure request.timeout.ms, delivery.timeout.ms, max.block.ms
✅ Callbacks run in I/O thread: Keep them fast
✅ DLQ pattern: Send failed messages to separate topic
✅ Circuit breaker: Stop sending if broker consistently failing
✅ Monitor and alert: Track failures, timeouts, DLQ size
Error Handling Checklist
- Configure retries appropriately
- Handle retriable vs non-retriable errors differently
- Validate data before serialization
- Implement Dead Letter Queue
- Offload slow work from callbacks
- Monitor failure rates
- Alert on anomalies
- Test failure scenarios (network partition, broker down)
- Document error handling strategy
What's Next
Module 7.1: Producer Internals
Understand what happens inside the producerModule 8: Performance & Tuning
Optimize producer for throughput and latency
Previous Module: ← 5.3 - Exactly-Once Semantics (Producer Side)
Next Module: 7.1 - Producer Internals →