Error handling and DLQ
StoatFlow has three independent exception handlers, one per failure stage: a record that fails to deserialize on the way in, a processor that throws while handling a record, and a production failure when serializing or sending an output record. Each handler decides whether to skip the record and continue, stop the application, or route the record to a dead-letter queue (DLQ) — and each is wired the same way, in code via streamsConfigOverrides or in application.yaml by class name.
For the design rationale — why these three stages, when "continue" is safe, and how DLQ records ride the same commit barrier as your output — see Error-handling model.
The surface follows Kafka Streams' KIP-1033 (processing exception handler, Kafka 3.9.0) and KIP-1034 (dead letter queue, Kafka 4.2.0), so it should look familiar if you have used those. Both were coauthored by Damien Gasparina, Loïc Greffier and Sébastien Viale at Michelin, and grew out of Michelin's Kstreamplify; their Current London 2025 talk Processing Exception Handling and Dead Letter Queue in Kafka Streams and Michelin's write-ups on processing error handling and the DLQ in Spring Kafka 4.1 are the best background.
The three handler stages
| Stage | Config property | Fires when | Default |
|---|---|---|---|
| Deserialization | deserializationExceptionHandler | A source record's key or value cannot be deserialized | LogAndFailDeserializationExceptionHandler |
| Processing | processingExceptionHandler | A processor throws during process() | LogAndFailProcessingExceptionHandler |
| Production | productionExceptionHandler | An output record fails to serialize or send | DefaultProductionExceptionHandler |
Each handler returns a decision:
CONTINUE— skip the failed record and keep processing. Optionally emit one or more DLQ records. On the asynchronous production path below it skips the record across an epoch replay rather than in place, but the effect is the same: the failed record is skipped, everything else is processed.FAIL— stop the application. The in-flight epoch's work is discarded and the process exits; see Architecture for the recovery behaviour on restart. Optionally emit DLQ records before failing. The source offsets are not advanced on any path, including the asynchronous production one, so the epoch replays on restart —FAILmeans stop before further harm.RETRY— production handlers only. On a synchronous send the runtime retries in place with exponential backoff, and writes any attached DLQ record only once those attempts are exhausted. On the asynchronous production path below the producer's own retries are already spent, soRETRYaborts and replays the epoch instead: identical toCONTINUEexcept the record is not quarantined, because the verdict says the record is fine and the send wasn't. UnderAT_LEAST_ONCEthere is no transaction to abort and no replay to offer, soRETRYon that path is treated asFAIL.
LogAndFail*). A malformed record stops the application until you choose a continue-or-DLQ policy — StoatFlow does not silently drop data unless you opt in.FAIL shutdown is a restart — and it will fail again. The pod exits, the kubelet starts it, the offending record is still at the same offset, and the same handler returns FAIL. That is the design: what you get is not recovery but a loud CrashLoopBackOff and an alert. Choose it deliberately — it is the right answer when a record you cannot read means something upstream is broken and processing should halt until a human looks. If you would rather keep running, handle the poison record: CONTINUE, or dead-letter it.This now describes the asynchronous production path too. It used to be the exception — the offsets had already advanced by the time FAIL was applied, so the restart resumed past the record and stopped once, after the loss. FAIL now holds the offsets on every path, so the loop above is what you get there as well: loud, and with the poison still on the topic where you can inspect it.Built-in handlers
All built-in handlers live in io.stoatflow.core.exception.
| Handler class | Stage | Behaviour |
|---|---|---|
LogAndFailDeserializationExceptionHandler | Deserialization | Log at error, then FAIL (default) |
LogAndContinueDeserializationExceptionHandler | Deserialization | Log at warn, then CONTINUE (record skipped) |
DeadLetterQueueDeserializationExceptionHandler | Deserialization | Log at warn, emit DLQ record, then CONTINUE |
LogAndFailProcessingExceptionHandler | Processing | Log at error, then FAIL (default) |
LogAndContinueProcessingExceptionHandler | Processing | Log at warn, then CONTINUE (record skipped) |
DeadLetterQueueProcessingExceptionHandler | Processing | Log at warn, emit DLQ record, then CONTINUE |
DefaultProductionExceptionHandler | Production | Retry retriable send errors; FAIL on serialization and non-retriable send errors; route to DLQ first if a dlqTopic is set (default) |
DefaultProductionExceptionHandler is the only built-in production handler; it covers retry-vs-fail classification and optional DLQ routing in one place, so you configure behaviour by whether you pass it a dlqTopic rather than by swapping the class.
Configuring a handler in code
Set handlers through streamsConfigOverrides on the :runtime builder. The same builder methods exist on StreamsConfig.builder(...) if you run on :core directly.
This example routes deserialization failures to a DLQ topic (skip + capture), keeps the fail-fast processing default, and adds a DLQ topic for production failures:
import io.stoatflow.core.exception.DeadLetterQueueDeserializationExceptionHandler
import io.stoatflow.core.exception.DefaultProductionExceptionHandler
import io.stoatflow.runtime.StoatFlowRuntime
import org.apache.kafka.common.serialization.Serdes
val runtime = StoatFlowRuntime.fromConfig(
topologyBuilder = { buildTopology(it) },
configure = {
streamsConfigOverrides {
defaultKeySerde(Serdes.String())
defaultValueSerde(Serdes.String())
deserializationExceptionHandler(
DeadLetterQueueDeserializationExceptionHandler(dlqTopic = "my-app.deserialization-errors.dlq"),
)
productionExceptionHandler(
DefaultProductionExceptionHandler(dlqTopic = "my-app.production-errors.dlq"),
)
}
},
)
runtime.start()
runtime.awaitTermination()
import io.stoatflow.core.exception.DeadLetterQueueDeserializationExceptionHandler;
import io.stoatflow.core.exception.DefaultProductionExceptionHandler;
import io.stoatflow.runtime.StoatFlowRuntime;
import org.apache.kafka.common.serialization.Serdes;
var runtime = StoatFlowRuntime.fromConfig(
Main::buildTopology,
builder -> builder.streamsConfigOverrides(cfg -> {
cfg.defaultKeySerde(Serdes.String());
cfg.defaultValueSerde(Serdes.String());
cfg.deserializationExceptionHandler(
new DeadLetterQueueDeserializationExceptionHandler("my-app.deserialization-errors.dlq"));
cfg.productionExceptionHandler(
new DefaultProductionExceptionHandler("my-app.production-errors.dlq"));
})
);
runtime.start();
runtime.awaitTermination();
To skip bad records without a DLQ, swap in the log-and-continue handler instead:
import io.stoatflow.core.exception.LogAndContinueDeserializationExceptionHandler
streamsConfigOverrides {
deserializationExceptionHandler(LogAndContinueDeserializationExceptionHandler())
}
import io.stoatflow.core.exception.LogAndContinueDeserializationExceptionHandler;
builder.streamsConfigOverrides(cfg ->
cfg.deserializationExceptionHandler(new LogAndContinueDeserializationExceptionHandler()));
The DLQ handlers take optional flags: includeStackTrace (default true) controls whether the full stack trace is written to a header, and the deserialization handler additionally takes includeOriginalHeaders (default true) to copy the source record's headers onto the DLQ record under a prefix.
Configuring a handler in YAML
The :runtime module can resolve handlers by fully qualified class name under stoatflow.*. Builder overrides (above) take precedence over YAML; YAML takes precedence over the defaults.
stoatflow:
application-id: my-app
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:-localhost:9092}
deserialization-exception-handler: io.stoatflow.core.exception.LogAndContinueDeserializationExceptionHandler
processing-exception-handler: io.stoatflow.core.exception.LogAndFailProcessingExceptionHandler
production-exception-handler: io.stoatflow.core.exception.DefaultProductionExceptionHandler
dlqTopic constructor argument, so they cannot be set from YAML directly — configure them in code via streamsConfigOverrides (above), or write a thin no-arg subclass that hard-codes the topic and reference that class from YAML. A class with no matching no-arg constructor fails at startup with a clear instantiation error.DLQ records and headers
When a DLQ handler fires, it builds a ProducerRecord targeting your configured dlqTopic and returns it to the engine. That record is sent through the same transactional producer as your normal output, committed atomically on the next commit barrier — so a DLQ record exists if and only if the rest of that epoch committed. No separate producer, no lost-or-duplicated error records.
max.message.bytes — the rejection poisons the in-flight transaction and nothing in it can commit. Rather than advancing past the epoch, the runtime holds the source offsets: it commits a secondary transaction carrying only the poison's DLQ record, quarantines the poison's source coordinates, restarts the engine in place, and reprocesses the epoch with exactly that record skipped. The other records in the epoch are processed again and commit normally.Loss is bounded to the poison record's own outputs and its state contribution — not zero. Skipping a source record skips all of its outputs, so a record that fans out to several sinks loses the good sends with the bad one; it is skipped before deserialization, so it never updates state either, and a count() over its key stays permanently one short. And a poison derived from accumulated state (an aggregate that outgrew max.message.bytes) does not converge: skipping the triggering record does not shrink the accumulator, so the next record on that key reproduces it. Each replay costs a full engine restart and consumes stoatflow.commit-barrier.max-poison-replays (default 10 per minute); that budget is in-memory, so exhausting it ends the process and the pod restarts with a clean budget — CONTINUE is bounded per process, not end to end.Two cases stop immediately instead, with the offsets held so an operator restart replays the epoch: a FAIL verdict, and a CONTINUE on a poison with no attributable source record (emitted by a punctuator, a window close, a suppression flush, a timer or a scheduled source — those planes fire on their own schedule and would re-poison every attempt, so the runtime names the plane and stops). A RETRY on one of those planes replays as usual: it says the send failed, not the emission.Alert on stoatflow.dlq.poison.replays.totalandstoatflow.dlq.poison.replay.budget.exhausted.total — the first stops incrementing exactly when the budget runs out, which is what the second catches; stoatflow.dlq.poison.quarantined.total and stoatflow.dlq.poison.quarantine.size show what is being skipped. Prevent the case entirely by sizing the target topic's max.message.bytes for your largest output, and size the DLQ topic at least as large as every topic that feeds it. Full rationale, including how Kafka Streams behaves on the same scenario: Error-handling model.The original key and value bytes are preserved where available:
- Deserialization failures carry the raw source bytes verbatim (key and value), since deserialization never succeeded.
- Processing failures serialize the in-flight key and value with
toString().toByteArray()— the raw bytes are no longer available after the record was deserialized for processing. - Production failures carry the (already-serialized) record bytes that failed to send — except a size rejection, which carries no value at all.
Size rejections carry no value
When the broker refuses an output record with RecordTooLargeException, the dead letter is emitted with a null value, three __stoatflow.errors.value.* markers, and a capped stack trace. Carrying the refused value would make the dead letter strictly larger than the record the broker just rejected — the original headers plus ten-odd metadata headers go on top of it — so a DLQ topic sized like the output topic would reject it too, and that second rejection surfaces only as a later commit-time failure with no dead letter written at all.
The payload is still recoverable: the dead letter records (__stoatflow.errors.topic, .partition, .offset), so seek to those coordinates on the source topic — while that topic's retention lasts. A short retention.ms on a busy source topic turns this recovery path into a dead link, so size the retention against your mean time-to-response.
Size the DLQ topic for the metadata too. The __stoatflow.errors.* header set is roughly 600 bytes before the stack trace, so a DLQ topic wants a few KiB of max.message.bytes no matter how small your values are.
Header reference
All DLQ headers use the __stoatflow.errors.* namespace. The double-underscore prefix follows the Kafka convention for system headers, so DLQ tooling that filters __-prefixed headers must opt in to surface these.
| Header key | Present on | Value |
|---|---|---|
__stoatflow.errors.type | all | DESERIALIZATION, PROCESSING, or PRODUCTION |
__stoatflow.errors.exception | all | Exception class name |
__stoatflow.errors.message | all | Exception message |
__stoatflow.errors.stacktrace | all (if includeStackTrace) | Stack trace — full, except capped on the size-rejection path |
__stoatflow.errors.topic | all | Original source topic |
__stoatflow.errors.partition | all | Original source partition |
__stoatflow.errors.offset | all | Original source offset |
__stoatflow.errors.component | deser, production | KEY / VALUE (deser); KEY_SERIALIZATION / VALUE_SERIALIZATION / SEND / PARTITIONER (production) |
__stoatflow.errors.processor | processing, production | Failing processor name; sink-node id on multi-sink topologies |
__stoatflow.errors.timestamp | processing | Record timestamp |
__stoatflow.errors.target.topic | production | The topic the record was being sent to |
__stoatflow.errors.value.omitted | production, size rejections | true — the value was dropped rather than carried |
__stoatflow.errors.value.size | production, size rejections | Serialized size in bytes of the omitted value |
__stoatflow.errors.value.omitted.reason | production, size rejections | Why it was dropped (record-too-large) |
__stoatflow.errors.original.<key> | deser (if includeOriginalHeaders) | Each original source header, copied under this prefix |
The deserialization handler is the only one that prefixes the failed record's own headers, and it appends them last. The processing and production handlers copy them verbatim and first, so lastHeader() on a __stoatflow.errors.* key always resolves to the error metadata rather than to a colliding original.
Inspect a DLQ record with the console consumer:
kafka-console-consumer.sh --bootstrap-server localhost:9092 \
--topic my-app.deserialization-errors.dlq --from-beginning \
--property print.headers=true --property print.key=true
Production-error classification
The production stage distinguishes errors that are worth retrying from those that are not. DefaultProductionExceptionHandler treats Kafka client RetriableExceptions and TimeoutException (including when they appear as the cause) as retriable and returns RETRY; serialization errors and non-retriable send errors return FAIL (routing to the DLQ first if a topic is configured).
Underneath, StoatFlow classifies producer exceptions into three categories that determine engine-level behaviour:
| Category | Meaning |
|---|---|
FATAL | The engine cannot recover — triggers shutdown regardless of handler. Includes producer-fencing, authentication/authorization, serialization, and broker-incompatibility errors. |
ABORTABLE | Routed to your productionExceptionHandler.handle(...); the handler's decision is respected. |
SUPPRESSED | Trace-logged only; a follow-up after a prior abort (e.g. TransactionAbortedException). |
Because StoatFlow runs as a single instance, there is no task to migrate a fenced producer to — so producer-fencing and related errors that Kafka Streams would treat as recoverable task migrations are FATAL here. The application exits and a fresh process resumes from the last committed barrier.
Custom handlers
Implement the handler interface for the stage you want to control. The handle methods use the Kafka Streams-compatible parameter order (context, record, exception): the context is the KS-shaped ErrorHandlerContext, and the record is the in-flight Record (processing), raw ConsumerRecord<ByteArray, ByteArray> (deserialization), or ProducerRecord<ByteArray?, ByteArray?> (production). A handler returns a response object combining the CONTINUE / FAIL decision with an optional list of DLQ ProducerRecords — the engine sends those transactionally for you.
import io.stoatflow.core.exception.ErrorHandlerContext
import io.stoatflow.core.exception.ProcessingExceptionHandler
import io.stoatflow.core.exception.ProcessingHandlerResponse
import io.stoatflow.core.processor.Record
class SkipNullPointerHandler : ProcessingExceptionHandler {
override fun handle(
context: ErrorHandlerContext,
record: Record<*, *>,
exception: Exception,
): ProcessingHandlerResponse =
if (exception is NullPointerException) {
ProcessingHandlerResponse.continueProcessing()
} else {
ProcessingHandlerResponse.fail()
}
}
import io.stoatflow.core.exception.ErrorHandlerContext;
import io.stoatflow.core.exception.ProcessingExceptionHandler;
import io.stoatflow.core.exception.ProcessingHandlerResponse;
import io.stoatflow.core.processor.Record;
public class SkipNullPointerHandler implements ProcessingExceptionHandler {
@Override
public ProcessingHandlerResponse handle(
ErrorHandlerContext context, Record<?, ?> record, Exception exception) {
if (exception instanceof NullPointerException) {
return ProcessingHandlerResponse.continueProcessing();
}
return ProcessingHandlerResponse.fail();
}
}
The ErrorHandlerContext carries the source topic, partition, offset, timestamp, headers, failing processor node id, and the raw source key/value bytes, so your handler can log or tag DLQ records precisely. To emit a DLQ record from a custom handler, build a ProducerRecord<ByteArray?, ByteArray?> and pass it to continueProcessing(listOf(...)) or fail(listOf(...)).
A custom handler referenced from YAML must have a public no-arg constructor; one configured in code can take any constructor arguments you like.
Application-level DLQ routing
The exception handlers above catch failures — records that throw or fail to (de)serialize. For business-rule rejections (a record that deserializes fine but fails your validation), route it explicitly inside the topology with an ordinary sink. This is just normal DSL: branch the invalid records and .to(...) a DLQ topic.
val validated = builder.stream<String, Order>("orders")
validated
.filterNot({ _, order -> order.isValid() }, Named.`as`("invalid"))
.to("orders.invalid.dlq", Produced.`as`("dlq-sink"))
validated
.filter({ _, order -> order.isValid() }, Named.`as`("valid"))
.mapValues({ order -> order.normalize() })
.to("orders.normalized")
KStream<String, Order> validated = builder.stream("orders");
validated
.filterNot((k, order) -> order.isValid(), Named.as("invalid"))
.to("orders.invalid.dlq", Produced.as("dlq-sink"));
validated
.filter((k, order) -> order.isValid(), Named.as("valid"))
.mapValues(Order::normalize)
.to("orders.normalized");
This pattern gives you full control over the DLQ record shape (you choose the serde and payload) and is the right tool when "this record is bad" is a domain decision rather than an exception. The stock-tick-filter example app uses exactly this approach to send validation failures to a DLQ topic in Avro form.
Related
- Error-handling model — the why behind the three stages, CONTINUE-vs-FAIL trade-offs, transactional DLQ delivery, and the one path where continue costs an epoch of output.
- Metrics reference — the
stoatflow.dlq.poison.*meters and thestoatflow.dropped.records.totalreason vocabulary. - Serdes — where deserialization failures originate, and how to make malformed data fail predictably.
- Event time and watermarks — late-record policies, which are configured separately from these exception handlers.
- Architecture — what a
FAILdecision does to the running process and how recovery works on restart.
Building topologies
How to define a StoatFlow topology — StreamsBuilder, the KStream/KTable abstractions, and the fan-out rule that reuses a KStream reference for multiple branches.
State stores
Use the Stores factory and Materialized to configure persistent and in-memory key-value, window, session, and versioned stores — and read them back via interactive queries.