4.2 KiB
← DefaultErrorHandler · Module README · The DLT →
3. Poison pills
A record whose bytes cannot be deserialized fails inside poll(), before any listener exists.
No error handler is in the path. The offset cannot advance, so the next poll fetches the same
record and fails identically. Forever.
The consumer is up, the group is stable, no exception reaches your code, and lag grows. It is one of the few Kafka failures with no good symptom.
ErrorHandlingDeserializer
spring:
kafka:
consumer:
value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
properties:
spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer
It wraps the real deserializer, catches the failure, and returns a null value with the exception in a header. The record then flows normally into the container, the listener is skipped, and the error handler gets a record it can recover — which is to say, the poison pill becomes an ordinary failure.
Use spring.deserializer.key.delegate.class for keys. A malformed key is rarer and just as
fatal.
What arrives on the DLT
From docs/output/poison-pill.txt:
kafka_dlt-exception-fqcn org.springframework.kafka.support.serializer.DeserializationException
kafka_dlt-exception-cause-fqcn org.springframework.kafka.support.serializer.DeserializationException
kafka_dlt-exception-message failed to deserialize
kafka_dlt-original-topic payments
kafka_dlt-original-partition 0x00000000
kafka_dlt-original-offset 0x0000000000000000
kafka_dlt-original-consumer-group payments
__TypeId__ [B
DLT payload -> "eyB0aGlzIGlzIG5vdCBqc29u"
The listener was never invoked — asserted in the test — and the record is off the partition, which is the whole win.
Two details in that block are worth stopping on.
original-partition and original-offset are binary. They are big-endian int and long,
not text. Printing them as a string gives you mojibake, which is why the transcript renders them
as hex. A DLT tool that treats every header as UTF-8 will show garbage for exactly the three
fields you need in order to find the original record.
The payload is base64. "eyB0aGlzIGlzIG5vdCBqc29u" decodes to { this is not json. The
recoverer publishes with the application's producer, whose value serializer is
JacksonJsonSerializer; the failed value is a byte[]; Jackson writes a byte[] as a base64
JSON string. So the DLT does not hold what arrived — it holds base64 of it, wrapped in quotes.
Replaying that topic naively republishes a quoted base64 string, which fails to deserialize, and now you have a poison pill in your poison-pill queue.
The fix
Give the recoverer a template per value type:
Map<Class<?>, KafkaOperations<?, ?>> templates = new LinkedHashMap<>();
templates.put(byte[].class, byteTemplate); // ByteArraySerializer
templates.put(Object.class, jsonTemplate);
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(templates);
=== byte-aware DLT ===
DLT payload -> { this is not json
Byte for byte what was published. Replay is now a copy from one topic to another.
Three things had to be right to get there, and each failed first — they are commented in
ErrorHandlerConfiguration:
KafkaAutoConfiguration's template is@ConditionalOnMissingBean(KafkaTemplate.class). DeclaringbyteTemplateremoved the auto-configuredKafkaTemplatefrom the context entirely. Once you declare one template, you own all of them.- With two templates present,
KafkaOperations<String, Object>stops resolving, and@Qualifieralone does not rescue it — the generic check runs first. UseKafkaOperations<?, ?>, which is what the recoverer's constructor wants anyway. - The map is
Map<Class<?>, KafkaOperations<?, ?>>, matched by value type, withObject.classas the fallback.