KafkaIQ

10.1 Error Handling & Failure Scenarios

Module Overview

Goal: Master error handling and failure recovery in Kafka Streams.

What You'll Learn:

  • Types of errors (transient vs permanent)
  • Uncaught exception handlers
  • Deserialization errors
  • Production exception handlers
  • Stream processing exceptions
  • Graceful shutdown
  • Recovery strategies

What's NOT Covered Here:

  • EOS internals (covered in Module 9.1)
  • State restoration (covered in Module 6.3)
  • Rebalancing (covered in Module 1.1)

Key Insight: Proper error handling is crucial for production resilience — distinguish between recoverable and non-recoverable errors and handle accordingly.


1. Error Types

Transient Errors

Transient = temporary, may succeed on retry

Examples:
├─ Network timeout
├─ Broker temporarily unavailable
├─ Resource temporarily exhausted
└─ Transient database connection issue

Handling:
└─ Retry (automatic or manual)

Permanent Errors

Permanent = fundamental problem, won't succeed on retry

Examples:
├─ Malformed data (parse error)
├─ Missing required field
├─ Business rule violation
└─ Authentication failure

Handling:
├─ Log error
├─ Skip record (or send to DLT)
└─ Continue processing

Fatal Errors

Fatal = application-level issue, cannot continue

Examples:
├─ Out of memory
├─ Disk full
├─ State store corruption (unrecoverable)
└─ Configuration error

Handling:
└─ Shut down application (restart by orchestrator)

2. Exception Handlers

Overview

Kafka Streams provides hooks for exception handling:

1. DeserializationExceptionHandler
   └─ Handle deserialization errors (input)

2. ProductionExceptionHandler
   └─ Handle production errors (output)

3. StreamsUncaughtExceptionHandler
   └─ Handle all other uncaught exceptions

Configure during application setup

3. Deserialization Exception Handler

The Problem

Input record cannot be deserialized:

Scenario:
├─ Producer writes invalid JSON
├─ Schema evolution breaks deserialization
├─ Corrupt data in Kafka
└─ Kafka Streams tries to deserialize → fails

Default behavior:
└─ Application crashes (FAIL fast)

Better approach:
└─ Skip bad record, log, continue

Built-in Handlers

// 1. LOG AND FAIL (default)
props.put(StreamsConfig.DEFAULT_DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG,
    LogAndFailExceptionHandler.class);

// Application crashes on deserialization error

// 2. LOG AND CONTINUE
props.put(StreamsConfig.DEFAULT_DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG,
    LogAndContinueExceptionHandler.class);

// Logs error, skips record, continues processing

Custom Handler

public class CustomDeserializationExceptionHandler 
    implements DeserializationExceptionHandler {

    @Override
    public DeserializationHandlerResponse handle(
        ProcessorContext context,
        ConsumerRecord<byte[], byte[]> record,
        Exception exception) {

        // Log error with context
        logger.error("Deserialization error: topic={}, partition={}, offset={}, error={}",
            record.topic(), record.partition(), record.offset(), exception.getMessage());

        // Send to Dead Letter Topic (DLT)
        try {
            deadLetterProducer.send(new ProducerRecord<>(
                "deserialization-errors",
                record.key(),
                record.value()
            ));
        } catch (Exception e) {
            logger.error("Failed to send to DLT", e);
        }

        // Decide: CONTINUE or FAIL
        return DeserializationHandlerResponse.CONTINUE;
    }

    @Override
    public void configure(Map<String, ?> configs) {
        // Initialize (e.g., DLT producer)
    }
}

Configuration

props.put(StreamsConfig.DEFAULT_DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG,
    CustomDeserializationExceptionHandler.class);

4. Production Exception Handler

The Problem

Output record cannot be written:

Scenario:
├─ Broker temporarily unavailable
├─ Topic doesn't exist
├─ Authorization failure
└─ Serialization error (output)

Default behavior:
└─ Application crashes

Better approach:
└─ Retry transient errors, fail on permanent errors

Built-in Handlers

// 1. DEFAULT PRODUCTION EXCEPTION HANDLER
props.put(StreamsConfig.DEFAULT_PRODUCTION_EXCEPTION_HANDLER_CLASS_CONFIG,
    DefaultProductionExceptionHandler.class);

// Fails on any error (safe, but strict)

// (No built-in "continue" handler for production)

Custom Handler

public class CustomProductionExceptionHandler 
    implements ProductionExceptionHandler {

    @Override
    public ProductionExceptionHandlerResponse handle(
        ProducerRecord<byte[], byte[]> record,
        Exception exception) {

        // Check error type
        if (exception instanceof RetriableException) {
            // Transient error (network, timeout)
            logger.warn("Transient production error, will retry: {}", 
                exception.getMessage());
            return ProductionExceptionHandlerResponse.CONTINUE;
        } else if (exception instanceof org.apache.kafka.common.errors.SerializationException) {
            // Permanent error (bad data)
            logger.error("Serialization error, skipping record: {}", 
                exception.getMessage());
            // Send to DLT
            sendToDeadLetterTopic(record);
            return ProductionExceptionHandlerResponse.CONTINUE;
        } else {
            // Unknown error, fail application
            logger.error("Fatal production error: {}", exception.getMessage());
            return ProductionExceptionHandlerResponse.FAIL;
        }
    }

    @Override
    public void configure(Map<String, ?> configs) {
        // Initialize
    }
}

Configuration

props.put(StreamsConfig.DEFAULT_PRODUCTION_EXCEPTION_HANDLER_CLASS_CONFIG,
    CustomProductionExceptionHandler.class);

5. Uncaught Exception Handler

The Problem

Exceptions not caught by other handlers:

Examples:
├─ NullPointerException in processing logic
├─ Business logic exception
├─ State store exception
└─ Any runtime exception

Default behavior (Kafka Streams < 3.0):
└─ Thread dies, instance may become zombie

Default behavior (Kafka Streams 3.0+):
└─ Application shuts down

Handler

public class CustomUncaughtExceptionHandler 
    implements StreamsUncaughtExceptionHandler {

    @Override
    public StreamThreadExceptionResponse handle(Throwable exception) {

        logger.error("Uncaught exception in Kafka Streams: {}", 
            exception.getMessage(), exception);

        // Classify error
        if (exception instanceof OutOfMemoryError) {
            // Fatal: Shut down and let orchestrator restart
            return StreamThreadExceptionResponse.SHUTDOWN_APPLICATION;
        } else if (exception instanceof NullPointerException) {
            // Likely a bug, but attempt to continue on other threads
            return StreamThreadExceptionResponse.REPLACE_THREAD;
        } else {
            // Unknown, shut down to be safe
            return StreamThreadExceptionResponse.SHUTDOWN_APPLICATION;
        }
    }
}

Response Types

REPLACE_THREAD:
├─ Kill current thread
├─ Start new thread
└─ Continue processing (if threads available)

SHUTDOWN_CLIENT:
├─ Shut down Kafka Streams instance
└─ Graceful shutdown

SHUTDOWN_APPLICATION:
├─ Shut down entire application
└─ Most aggressive (use for fatal errors)

Configuration

KafkaStreams streams = new KafkaStreams(topology, props);

streams.setUncaughtExceptionHandler(new CustomUncaughtExceptionHandler());

streams.start();

6. Processing Errors (in Topology)

The Problem

Errors during record processing:

Example:
stream.mapValues(value -> {
    return value.toUpperCase();  // NPE if value is null
});

If not handled:
└─ Uncaught exception → thread dies

Strategy 1: Try-Catch in Lambda

stream.mapValues(value -> {
    try {
        return value.toUpperCase();
    } catch (Exception e) {
        logger.error("Error processing: {}", e.getMessage());
        return "ERROR";  // Fallback value
    }
});

Strategy 2: Branching

// Split valid and invalid records
Map<String, KStream<String, Order>> branches = stream.split()
    .branch((k, v) -> isValid(v), Branched.as("valid"))
    .branch((k, v) -> !isValid(v), Branched.as("invalid"))
    .noDefaultBranch();

// Process valid records
branches.get("valid")
    .mapValues(this::processOrder)
    .to("output-topic");

// Send invalid records to DLT
branches.get("invalid")
    .to("dead-letter-topic");

Strategy 3: Custom Processor

stream.process(() -> new Processor<String, Order, String, ProcessedOrder>() {
    @Override
    public void process(Record<String, Order> record) {
        try {
            ProcessedOrder result = processOrder(record.value());
            context().forward(record.withValue(result));
        } catch (Exception e) {
            logger.error("Error processing order {}: {}", 
                record.key(), e.getMessage());
            // Send to DLT
            context().forward(record, "dead-letter-topic");
        }
    }
});

7. Dead Letter Topics (DLT)

What is a DLT?

Dead Letter Topic (DLT) = topic for failed records

Purpose:
├─ Store records that cannot be processed
├─ Prevent application crash
├─ Allow later analysis/reprocessing
└─ Decouple error handling from main flow

Pattern:
├─ Catch error
├─ Send to DLT
└─ Continue processing

DLT Implementation

// Separate producer for DLT
Producer<String, String> dltProducer = new KafkaProducer<>(dltProps);

// In exception handler
public void sendToDeadLetterTopic(ConsumerRecord<byte[], byte[]> record, Exception error) {
    ProducerRecord<String, String> dltRecord = new ProducerRecord<>(
        "my-app-dlt",
        new String(record.key()),
        new String(record.value())
    );

    // Add headers with error info
    dltRecord.headers().add("error.message", error.getMessage().getBytes());
    dltRecord.headers().add("error.class", error.getClass().getName().getBytes());
    dltRecord.headers().add("original.topic", record.topic().getBytes());
    dltRecord.headers().add("original.partition", 
        String.valueOf(record.partition()).getBytes());
    dltRecord.headers().add("original.offset", 
        String.valueOf(record.offset()).getBytes());

    dltProducer.send(dltRecord);
}

DLT Best Practices

1. Include error context in headers
   └─ Error message, class, stack trace

2. Include original record metadata
   └─ Topic, partition, offset

3. Monitor DLT
   └─ Alert on high DLT volume

4. Process DLT asynchronously
   └─ Separate consumer analyzes/fixes records

5. Retention policy
   └─ Longer retention (weeks/months)

8. Graceful Shutdown

Why Graceful Shutdown?

Ungraceful shutdown:
├─ Kill -9 (SIGKILL)
├─ No cleanup
├─ In-flight transactions aborted
├─ State not flushed
└─ Long recovery on restart

Graceful shutdown:
├─ Finish current processing
├─ Commit transactions
├─ Flush state stores
├─ Close resources
└─ Fast restart

Implementation

KafkaStreams streams = new KafkaStreams(topology, props);

// Graceful shutdown on SIGTERM
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    logger.info("Shutdown signal received, closing Kafka Streams");
    streams.close(Duration.ofSeconds(30));  // Wait up to 30 seconds
    logger.info("Kafka Streams closed");
}));

streams.start();

// Keep main thread alive
streams.state();  // Or use CountDownLatch

Shutdown Timeout

// Close with timeout
streams.close(Duration.ofSeconds(30));

// If timeout exceeded:
├─ Force close
├─ May lose in-flight data
└─ Longer recovery

Recommendation: 30-60 seconds for large state

State Listener

streams.setStateListener((newState, oldState) -> {
    logger.info("State transition: {} -> {}", oldState, newState);

    if (newState == State.REBALANCING) {
        logger.warn("Rebalancing started");
    } else if (newState == State.RUNNING) {
        logger.info("Application running");
    } else if (newState == State.ERROR) {
        logger.error("Application in error state");
    }
});

9. Failure Scenarios & Recovery

Scenario 1: Broker Down

Failure:
├─ Kafka broker goes down
└─ Producer/consumer cannot connect

Kafka Streams behavior:
├─ Retries automatically (retries=Integer.MAX_VALUE)
├─ Waits for broker to return
└─ Resumes processing when available

Action: None (automatic recovery)

Scenario 2: Instance Crash

Failure:
├─ Kafka Streams instance crashes (OOM, kill -9)
└─ Tasks unprocessed

Kafka Streams behavior:
├─ Heartbeat stops
├─ Group coordinator detects timeout
├─ Rebalance triggered
├─ Tasks reassigned to other instances
└─ State restored from changelog

Recovery time:
├─ Without standby: Minutes (restore from changelog)
└─ With standby: Seconds (standby promoted)

Action: Restart instance (orchestrator)

Scenario 3: State Store Corruption

Failure:
├─ RocksDB corruption
└─ Cannot read state

Kafka Streams behavior:
├─ Detects corruption
├─ Deletes corrupted state
├─ Restores from changelog
└─ Resumes processing

Recovery time: Minutes (restore time)

Action: Monitor and ensure changelog intact

Scenario 4: Rebalance Storm

Failure:
├─ Frequent rebalances
├─ Tasks constantly reassigned
└─ No progress

Causes:
├─ max.poll.interval.ms too low
├─ Processing too slow
├─ Network instability
└─ Instance flapping

Solutions:
1. Increase max.poll.interval.ms
2. Optimize processing
3. Fix network issues
4. Stabilize instances

10. Resilience Patterns

Pattern 1: Circuit Breaker

// Track failures
AtomicInteger failureCount = new AtomicInteger(0);
int FAILURE_THRESHOLD = 5;

stream.mapValues(value -> {
    if (failureCount.get() > FAILURE_THRESHOLD) {
        // Circuit open: Fast fail
        throw new CircuitBreakerOpenException();
    }

    try {
        Result result = externalService.call(value);
        failureCount.set(0);  // Reset on success
        return result;
    } catch (Exception e) {
        failureCount.incrementAndGet();
        throw e;
    }
});

Pattern 2: Fallback

stream.mapValues(value -> {
    try {
        return enrichWithExternalData(value);
    } catch (Exception e) {
        logger.warn("External enrichment failed, using fallback");
        return enrichWithCache(value);  // Fallback to cache
    }
});

Pattern 3: Retry with Backoff

public Result processWithRetry(Order order) {
    int maxRetries = 3;
    int retryDelay = 100;  // ms

    for (int i = 0; i < maxRetries; i++) {
        try {
            return externalService.process(order);
        } catch (TransientException e) {
            if (i == maxRetries - 1) throw e;  // Last retry
            Thread.sleep(retryDelay * (i + 1));  // Exponential backoff
        }
    }
    throw new RuntimeException("Max retries exceeded");
}

11. Monitoring for Failures

Key Metrics

# Failed records (deserialization)
kafka.streams:type=stream-metrics,
              client-id=*,
              name=failed-stream-threads

# Errors per second
kafka.streams:type=stream-task-metrics,
              task-id=*,
              name=error-rate

# Thread state
kafka.streams:type=stream-thread-metrics,
              thread-id=*,
              name=thread-state

# Commit failures
kafka.streams:type=stream-thread-metrics,
              thread-id=*,
              name=commit-latency-max

Logging

// Structured logging for debugging
logger.error("Processing error: topic={}, partition={}, offset={}, key={}, error={}",
    context.topic(),
    context.partition(),
    context.offset(),
    record.key(),
    exception.getMessage(),
    exception);

Alerting

Alert on:
├─ DLT volume spike (> threshold)
├─ Application state = ERROR
├─ High deserialization error rate
├─ Frequent restarts
└─ Consumer lag growing

12. Testing Error Handling

Unit Testing with TopologyTestDriver

@Test
public void testDeserializationError() {
    // Create topology with custom exception handler
    Topology topology = buildTopology();
    TopologyTestDriver testDriver = new TopologyTestDriver(topology, props);

    TestInputTopic<String, String> inputTopic = 
        testDriver.createInputTopic("input", stringSerde, stringSerde);

    // Send invalid record
    inputTopic.pipeInput("key", "invalid-json");

    // Verify: No crash, record skipped
    // (Check DLT or logs)
}

Integration Testing

@Test
public void testBrokerFailure() throws Exception {
    // Start Kafka Streams
    KafkaStreams streams = new KafkaStreams(topology, props);
    streams.start();

    // Simulate broker failure
    kafkaContainer.stop();

    // Wait (Streams should retry)
    Thread.sleep(10000);

    // Restart broker
    kafkaContainer.start();

    // Verify: Streams recovers and processes
    // (Check output topic)
}

13. Best Practices

1. Use LogAndContinue for Deserialization

// Don't crash on bad data
props.put(StreamsConfig.DEFAULT_DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG,
    LogAndContinueExceptionHandler.class);

2. Implement Dead Letter Topics

// Capture failed records for analysis
// Don't lose data silently

3. Set Uncaught Exception Handler

// Always set handler (default changed in 3.0+)
streams.setUncaughtExceptionHandler(new CustomUncaughtExceptionHandler());

4. Use Graceful Shutdown

// Always close gracefully
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    streams.close(Duration.ofSeconds(30));
}));

5. Monitor and Alert

Track:
├─ Error rates
├─ DLT volume
├─ Application state
└─ Recovery time

Alert on anomalies

6. Test Failure Scenarios

Test:
├─ Deserialization errors
├─ Processing exceptions
├─ Broker failures
├─ Instance crashes
└─ Rebalances

Verify recovery behavior

14. Mental Model Summary

Exception Handler Hierarchy

┌─────────────────────────────────────────────┐
│  DeserializationExceptionHandler            │
│  └─ Input deserialization errors            │
└─────────────────────────────────────────────┘
          ↓ (not handled)
┌─────────────────────────────────────────────┐
│  Processing (your code)                     │
│  └─ Try-catch in lambda/processor           │
└─────────────────────────────────────────────┘
          ↓ (not handled)
┌─────────────────────────────────────────────┐
│  ProductionExceptionHandler                 │
│  └─ Output production errors                │
└─────────────────────────────────────────────┘
          ↓ (not handled)
┌─────────────────────────────────────────────┐
│  StreamsUncaughtExceptionHandler            │
│  └─ All other uncaught exceptions           │
└─────────────────────────────────────────────┘
          ↓ (not handled)
┌─────────────────────────────────────────────┐
│  Application crash / Thread dies            │
└─────────────────────────────────────────────┘

Error Handling Decision Tree

Error occurs:
├─ Transient (network, timeout)?
│  └─ Retry (automatic or manual)
│
├─ Permanent (bad data, parse error)?
│  ├─ Log error
│  ├─ Send to DLT
│  └─ Continue processing
│
└─ Fatal (OOM, corruption)?
   └─ Shut down, let orchestrator restart

15. Questions You Should Be Able to Answer

After this module, you should be able to answer:

✅ What are the three types of exception handlers in Kafka Streams?
✅ What's the difference between transient and permanent errors?
✅ How do I handle deserialization errors without crashing?
✅ What is a Dead Letter Topic (DLT)?
✅ How do I implement graceful shutdown?
✅ What happens when an instance crashes?
✅ How do I monitor for failures?
✅ When should I use REPLACE_THREAD vs SHUTDOWN_APPLICATION?


16. What's Next?

Now that you understand error handling, the next modules will cover:

  • Module 11.1: Interactive queries & testing — querying state and testing topologies
  • Module 12.1: Monitoring — operational metrics and dashboards
  • Module 13.1: Advanced configuration — performance tuning

Summary

You learned:

  • ✅ Three exception handlers: Deserialization, Production, Uncaught
  • ✅ Transient errors → retry, Permanent errors → skip/DLT, Fatal errors → shutdown
  • ✅ LogAndContinueExceptionHandler for deserialization (don't crash on bad data)
  • ✅ Dead Letter Topics (DLT) capture failed records for analysis
  • ✅ Graceful shutdown with streams.close(timeout) and shutdown hook
  • ✅ Automatic recovery from broker/instance failures via rebalancing
  • ✅ Monitor error rates, DLT volume, and application state
  • ✅ Test failure scenarios (bad data, crashes, broker down) to verify resilience

Next up: Module 11.1 — Interactive Queries & Testing — Learn how to query Kafka Streams state and test your topologies effectively.