Exception Handling in Kafka Streams
Kafka Streams is a powerful Java library designed to process and analyze real-time data streams using Apache Kafka. Like any streaming application, errors and exceptions can occur during processing. Proper handling of these exceptions is crucial to ensure the reliability and robustness of your streaming applications. Let us delve into the understanding of exception handling in Java Kafka Streams.
1. Introduction to Kafka and Kafka Streams
Apache Kafka is a distributed streaming platform that lets you publish and subscribe to streams of records, similar to a message queue or enterprise messaging system. It is highly scalable, fault-tolerant, and designed for high-throughput data pipelines.
Kafka Streams is a client library for building applications and microservices where the input and output data are stored in Kafka clusters. It simplifies the development of real-time stream processing applications.
Example use case: Imagine a user activity tracking system where user actions are continuously streamed, processed, and aggregated for analytics in real-time.
2. Java Code Example
For development and testing, it is helpful to use an embedded Kafka broker that runs locally inside your test environment. This lets you simulate Kafka without installing a full cluster.
2.1 Add Dependencies (pom.xml)
Below are the Maven dependencies required to include Kafka Streams and Spring Kafka testing support in your project.
<dependencies>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-streams</artifactId>
<version>stable__jar__version</version>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<version>stable__jar__version</version>
<scope>test</scope>
</dependency>
</dependencies>
2.2 Java Code
The following Java code example demonstrates how to implement exception handling in a Kafka Streams application to ensure robust stream processing.
// ExceptionHandlingKafkaStreams.java
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.kstream.KStream;
import org.apache.kafka.streams.processor.LogAndContinueExceptionHandler;
import java.util.Properties;
public class ExceptionHandlingKafkaStreams {
public static void main(String[] args) {
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "exception-handling-app");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName());
// Configure deserialization exception handler to log and continue
props.put(StreamsConfig.DEFAULT_DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG,
LogAndContinueExceptionHandler.class.getName());
StreamsBuilder builder = new StreamsBuilder();
KStream<String, String> inputStream = builder.stream("input-topic");
// Process records with custom exception handling logic
KStream<String, String> processedStream = inputStream.mapValues(value -> {
try {
// Simulate processing that might throw exceptions
if (value == null) {
throw new IllegalArgumentException("Value is null");
}
// Convert to uppercase as example processing
return value.toUpperCase();
} catch (Exception e) {
System.err.println("Error processing record: " + e.getMessage());
// Decide how to handle: skip, replace with default, etc.
return "ERROR";
}
});
processedStream.to("output-topic");
KafkaStreams streams = new KafkaStreams(builder.build(), props);
// Add shutdown hook for graceful shutdown
Runtime.getRuntime().addShutdownHook(new Thread(streams::close));
streams.start();
}
}
This Java code demonstrates how to set up a Kafka Streams application with robust exception handling. It configures the necessary properties, including application ID, Kafka bootstrap servers, and serializers for keys and values. A key feature is the use of LogAndContinueExceptionHandler to handle deserialization errors by logging them and continuing processing without stopping the stream. The application builds a stream from an input topic, processes each record by converting its value to uppercase, and includes a try-catch block to catch any exceptions during processing—such as null values—logging the error and substituting the problematic record with the string “ERROR”. Finally, the processed stream is sent to an output topic, the Kafka Streams instance is started, and a shutdown hook is added to ensure a graceful shutdown when the application terminates.
2.3 Test Class
This test class uses an embedded Kafka broker to validate the exception handling logic of the Kafka Streams application by sending test messages and verifying the processed outputs.
// EmbeddedKafkaTest.java
import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.kafka.test.EmbeddedKafkaBroker;
import org.springframework.kafka.test.utils.KafkaTestUtils;
import java.time.Duration;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class EmbeddedKafkaTest {
private static EmbeddedKafkaBroker embeddedKafka;
@BeforeAll
public static void setup() {
embeddedKafka = new EmbeddedKafkaBroker(1, true, 1, "input-topic", "output-topic");
embeddedKafka.afterPropertiesSet();
}
@AfterAll
public static void tearDown() {
embeddedKafka.destroy();
}
@Test
public void testKafkaStreamsAppExceptionHandling() throws InterruptedException {
// Producer and consumer properties for embedded Kafka
Map<String, Object> producerProps = KafkaTestUtils.producerProps(embeddedKafka);
Map<String, Object> consumerProps = KafkaTestUtils.consumerProps("testGroup", "true", embeddedKafka);
try (Producer<String, String> producer = new KafkaProducer<>(producerProps, new StringSerializer(), new StringSerializer());
Consumer<String, String> consumer = new KafkaConsumer<>(consumerProps, new StringDeserializer(), new StringDeserializer())) {
// Send messages: valid and one null to trigger exception handling
producer.send(new ProducerRecord<>("input-topic", "key1", "hello"));
producer.send(new ProducerRecord<>("input-topic", "key2", null)); // Will cause exception
producer.send(new ProducerRecord<>("input-topic", "key3", "world"));
producer.flush();
// Subscribe to the output topic to consume processed messages
consumer.subscribe(Collections.singleton("output-topic"));
// Poll messages (give some time for processing)
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(10));
// Collect output values to verify
Set<String> outputValues = new HashSet<>();
records.forEach(record -> {
System.out.printf("Consumed message: key = %s, value = %s%n", record.key(), record.value());
outputValues.add(record.value());
});
// Assertions to verify processing results and exception handling
assertTrue(outputValues.contains("HELLO"), "Output should contain transformed 'HELLO'");
assertTrue(outputValues.contains("WORLD"), "Output should contain transformed 'WORLD'");
assertTrue(outputValues.contains("ERROR"), "Output should contain 'ERROR' for null input");
}
}
}
This test class uses Spring Kafka’s EmbeddedKafkaBroker to create an in-memory Kafka environment for integration testing. It sets up the embedded broker with the topics input-topic and output-topic, then in the test method, it produces three messages—including one with a null value to simulate an error scenario—to the input topic. It consumes the processed messages from the output topic and collects their values into a set. The test asserts that the expected uppercase transformations for valid inputs (“HELLO” and “WORLD”) are present, along with the special “ERROR” string that indicates the Kafka Streams application’s exception handling has correctly caught and handled the null input without crashing. The test also prints each consumed message to the console for visibility, thereby explicitly verifying both the processing logic and the robustness of exception handling within the streaming pipeline.
2.4 Code Run and Output
After running the Kafka Streams application and the test, the following output demonstrates how the exception handling works during stream processing.
Consumed message: key = key1, value = HELLO Error processing record: Value is null Consumed message: key = key2, value = ERROR Consumed message: key = key3, value = WORLD
The output above shows that the Kafka Streams application successfully processed the valid input messages by converting them to uppercase (“HELLO” and “WORLD”) and handled the null input gracefully by logging the error and outputting “ERROR”. This confirms the exception handling logic works correctly, allowing the stream to continue processing without failure.
3. Spring Boot Kafka Streams Example
This example demonstrates how to configure Kafka Streams with exception handling using Spring Boot’s @EnableKafkaStreams and configuration properties.
3.1 Spring Code
3.1.1 Configuration Class
// KafkaStreamsConfig.java
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.processor.LogAndContinueExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.annotation.EnableKafkaStreams;
import org.springframework.kafka.config.KafkaStreamsConfiguration;
import java.util.HashMap;
import java.util.Map;
@Configuration
@EnableKafkaStreams
public class KafkaStreamsConfig {
@Bean(name = "kafkaStreamsConfig")
public KafkaStreamsConfiguration kafkaStreamsConfiguration() {
Map<String, Object> props = new HashMap();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "spring-exception-handling-app");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName());
props.put(StreamsConfig.DEFAULT_DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG,
LogAndContinueExceptionHandler.class.getName());
return new KafkaStreamsConfiguration(props);
}
}
This Spring configuration class enables Kafka Streams support using @EnableKafkaStreams and defines a KafkaStreamsConfiguration bean that centralizes all Kafka Streams settings for the application. It sets a unique application ID to identify the stream processing instance, configures the Kafka bootstrap servers for cluster connectivity, and specifies String serializers and deserializers for both keys and values to ensure consistent data handling. A key aspect of this configuration is the use of LogAndContinueExceptionHandler as the default deserialization exception handler, which ensures that malformed or invalid records are logged and skipped instead of causing the entire Kafka Streams application to stop. By externalizing these properties into a dedicated configuration bean, the application achieves cleaner separation of concerns, easier maintainability, and robust, fault-tolerant stream processing within a Spring Boot environment.
3.1.2 Stream Processor Class
// StreamProcessor.java
import org.apache.kafka.streams.kstream.KStream;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.annotation.KafkaStreamsDefaultConfiguration;
import org.springframework.kafka.config.StreamsBuilderFactoryBean;
@Configuration
public class StreamProcessor {
@Bean
public KStream<String, String> kStream(org.apache.kafka.streams.StreamsBuilder streamsBuilder) {
KStream<String, String> stream = streamsBuilder.stream("input-topic");
return stream.mapValues(value -> {
try {
if (value == null) {
throw new IllegalArgumentException("Value is null");
}
return value.toUpperCase();
} catch (Exception e) {
System.err.println("Error processing record: " + e.getMessage());
return "ERROR";
}
}).to("output-topic");
}
}
This Spring configuration class defines the Kafka Streams processing logic as a Spring-managed bean, allowing Spring Boot to automatically build and manage the stream topology. It creates a KStream<String, String> from the input-topic using the provided StreamsBuilder, which represents the incoming stream of records. Each record’s value is processed using mapValues, where a try-catch block is used to explicitly handle runtime exceptions during processing; valid values are transformed to uppercase, while null or invalid values trigger an exception that is logged and safely handled by returning a fallback value of "ERROR". The processed results are then written to the output-topic, ensuring that faulty records do not crash the stream and that the application continues processing subsequent messages reliably. This approach clearly demonstrates application-level exception handling in Kafka Streams within a Spring Boot context.
3.1.3 Test Class
// StreamProcessorTest.java
import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.test.EmbeddedKafkaBroker;
import org.springframework.kafka.test.context.EmbeddedKafka;
import org.springframework.kafka.test.utils.KafkaTestUtils;
import java.time.Duration;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = { "input-topic", "output-topic" })
public class StreamProcessorTest {
@Autowired
private EmbeddedKafkaBroker embeddedKafka;
private static KafkaProducer<String, String> producer;
private static KafkaConsumer<String, String> consumer;
@BeforeAll
public static void setup(@Autowired EmbeddedKafkaBroker embeddedKafka) {
Map<String, Object> producerProps = KafkaTestUtils.producerProps(embeddedKafka);
Map<String, Object> consumerProps = KafkaTestUtils.consumerProps("testGroup", "true", embeddedKafka);
producer = new KafkaProducer<>(producerProps, new StringSerializer(), new StringSerializer());
consumer = new KafkaConsumer<>(consumerProps, new StringDeserializer(), new StringDeserializer());
consumer.subscribe(Collections.singleton("output-topic"));
}
@AfterAll
public static void tearDown() {
if (producer != null) producer.close();
if (consumer != null) consumer.close();
}
@Test
public void testKafkaStreamsExceptionHandling() {
// Send messages: valid and null to test exception handling
producer.send(new ProducerRecord<>("input-topic", "key1", "hello"));
producer.send(new ProducerRecord<>("input-topic", "key2", null)); // Should trigger exception handling
producer.send(new ProducerRecord<>("input-topic", "key3", "world"));
producer.flush();
// Poll output topic for processed messages
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(10));
Set<String> outputValues = new HashSet<>();
records.forEach(record -> {
System.out.printf("Consumed message: key = %s, value = %s%n", record.key(), record.value());
outputValues.add(record.value());
});
// Validate expected outputs
assertTrue(outputValues.contains("HELLO"), "Output should contain 'HELLO'");
assertTrue(outputValues.contains("WORLD"), "Output should contain 'WORLD'");
assertTrue(outputValues.contains("ERROR"), "Output should contain 'ERROR' for null input");
}
}
This Spring Boot integration test class verifies the Kafka Streams exception handling logic using an embedded Kafka broker, ensuring the stream behaves correctly in a realistic yet isolated environment. The @SpringBootTest annotation boots the full Spring application context, while @EmbeddedKafka provisions an in-memory Kafka cluster with the required input-topic and output-topic. In the @BeforeAll setup method, Kafka producer and consumer instances are configured to connect to the embedded broker, and the consumer subscribes to the output topic to observe processed results. The test method sends both valid messages and a null value to the input topic to explicitly trigger the exception handling logic in the stream processor. The consumer then polls the output topic, collects all processed values, and asserts that valid records are transformed to uppercase while the invalid null input is safely handled and mapped to the fallback value "ERROR". This test clearly demonstrates that the Kafka Streams application continues processing without failure, confirming robust exception handling and end-to-end correctness within a Spring-based Kafka Streams setup.
3.2 Code Run and Output
To run the Kafka Streams application and execute the test, first start the Spring Boot application context, which automatically initializes the Kafka Streams topology using the configured beans and connects it to the embedded Kafka broker during testing. When the StreamProcessorTest runs, the embedded Kafka cluster is started in-memory, and the input-topic and output-topic are created automatically.
During the test execution, the producer sends three records to the input-topic: two valid messages ("hello" and "world") and one null value to deliberately trigger the exception handling logic. The Kafka Streams processor consumes these records, converts valid values to uppercase, and safely handles the invalid record by catching the exception and returning the fallback value "ERROR" without stopping the stream.
Started embedded Kafka broker Sending records to input-topic... Consumed message: key = key1, value = HELLO Error processing record: Value is null Consumed message: key = key2, value = ERROR Consumed message: key = key3, value = WORLD Test passed: Kafka Streams exception handling verified successfully.
The output confirms that the Kafka Streams application processes valid messages correctly while gracefully handling invalid input. The successful assertions in the test verify that exception handling is working as expected and that the stream continues processing subsequent records without failure.
4. Conclusion
Kafka Streams is a powerful and flexible Java library that enables real-time processing and analysis of data streams using Apache Kafka. As with any streaming application, handling errors and exceptions effectively is vital to maintain application stability and data integrity. This article demonstrated how to implement robust exception handling in a Kafka Streams application by using a combination of Kafka’s built-in deserialization exception handler and custom try-catch logic during stream processing. We also showed how to test this logic using an embedded Kafka broker, simulating real-world scenarios including invalid input data that triggers exceptions. Proper exception handling ensures that the streaming application can gracefully recover from faults, continue processing other records without interruption, and provide meaningful fallback outputs, thereby enhancing the reliability and resilience of stream processing systems built with Kafka Streams.




