Poison Message Handling
Un message qui plante systématiquement le consumer (NullPointerException, OutOfMemoryError, parsing infini) ne sera jamais consommé. Dans un broker FIFO ordonné, il bloque toute la partition derrière lui. Le pattern poison message le détecte et l'isole.
Problème
Un consumer Kafka qui plante sur le même message à chaque tentative produit le pire scénario opérationnel : la partition entière s'arrête, les messages suivants n'avancent plus, le lag explose, les SLO partenaire sont violés. Causes typiques : un EDIFACT malformé déclenche une regex catastrophic backtracking (CVE), un INVOIC avec 100 000 lignes provoque un OOM, un payload contenant un caractère UTF-8 invalide casse le parser. Le message n'est ni légitime ni rejetable proprement — il tue le consumer. Sans détection, le consumer redémarre, tente le même message, replante, redémarre, ad infinitum.
Forces
- Ordonnancement : si le broker garantit l'ordre par partition (Kafka, SQS FIFO), le poison message bloque tout ce qui vient après.
- Détection : le marqueur d'un poison message est répétition d'exception identique au-delà d'un seuil, pas la première occurrence.
- Coût d'erreur : un faux positif (skipper un message légitime) peut être grave (facture perdue). Un faux négatif (consumer en boucle infinie) bloque tout.
- Diagnostic : l'analyse du message poison requiert un environnement isolé (sandbox debug, attention aux payloads malveillants).
- Audit : tout skip de poison message doit être traçable et notifiable (alerte ops + ticket d'analyse obligatoire).
Solution
Combiner trois mécanismes :
- Compteur d'échec par message : tracker
(message_offset, attempts)en mémoire process ou en table dédiée. Au-delà de N tentatives (typiquement 5), classifier le message comme poison. - Quarantine queue : dès la détection, copier le message dans une queue dédiée
edi.poison(DLQ sécurisée), commit l'offset original pour débloquer la partition, alerter les ops. - Sandbox d'analyse : un consumer isolé, ressource-bounded (CPU/RAM cap), instrumenté qui tente de re-parser le message poison pour produire un rapport d'erreur précis sans risquer le runtime production.
Structure
Consumer normal:
while true {
msg = broker.poll(timeout: 30s);
if not msg: continue;
attempts = poisonCounter.get(msg.id);
if attempts >= POISON_THRESHOLD {
// ─── Détection poison
quarantineQueue.publish(msg, headers={
"x-poison-cause": lastException,
"x-attempts": attempts,
"x-original-topic": msg.topic,
"x-detected-at": now()
});
alerter.fire("PoisonMessageDetected", {msg: msg.id, partition: msg.partition});
broker.commit(msg.offset); // débloque la partition
poisonCounter.reset(msg.id);
continue;
}
try {
process(msg);
broker.commit(msg.offset);
poisonCounter.reset(msg.id);
} catch (Exception e) {
poisonCounter.increment(msg.id);
log.warn("Failed attempt {} for {}: {}", attempts + 1, msg.id, e);
// PAS de commit → retry au prochain poll
sleep(exponentialBackoff(attempts));
}
} Implémentation EDI
Pour un hub EDI, la stratégie typique sur Kafka :
-- Table de compteur poison (PostgreSQL)
CREATE TABLE poison_counter (
topic VARCHAR(80) NOT NULL,
partition_id INT NOT NULL,
offset BIGINT NOT NULL,
consumer_group VARCHAR(80) NOT NULL,
attempts INT DEFAULT 0,
last_exception TEXT,
last_attempt_at TIMESTAMPTZ,
PRIMARY KEY (topic, partition_id, offset, consumer_group)
);
-- Index TTL pour purge (cleanup quotidien)
CREATE INDEX poison_counter_old
ON poison_counter (last_attempt_at)
WHERE last_attempt_at < now() - INTERVAL '7 days';
-- Sandbox d'analyse (consumer isolé sur quarantine queue)
@KafkaListener(
topics = "edi.poison",
containerFactory = "sandboxFactory" -- 256MB heap, 30s CPU cap
)
public void analyzePoison(ConsumerRecord<String, byte[]> record) {
try {
Object parsed = parseAttempt(record.value());
// Si on arrive ici, c'est un vrai problème de schema/version
reportToOps(record, parsed, null);
} catch (Throwable t) {
// Capture stack trace complète, dumps, etc.
forensicReport(record, t);
// Quarantaine définitive avec rapport détaillé
archiveBucket.upload("forensic/" + record.offset(), record.value(), t);
}
}
Cas spécifique EDIFACT : les messages malformés peuvent contenir des
caractères de contrôle (chars 0x00-0x1F hors UNA segment terminators) qui font
crasher certains parsers Java legacy. Toujours pré-valider avec un guard
léger (UNB présent, longueur < 10MB, charset valide) avant
d'invoquer le parser EDIFACT lourd. Ce guard évite 95 % des poison messages
d'origine partenaire.
Anti-patterns
- Auto-skip silencieux après N retries — produit des pertes silencieuses, l'audit explose.
- Pas de compteur persisté — un redémarrage consumer reset le counter et la boucle infinie reprend.
- Analyser le poison message dans le même process que le consumer principal — risque de re-tuer le runtime.
- Confondre poison message et message en DLQ — le DLQ est un échec connu et catalogué ; le poison message est un crash inexpliqué.
- Pas de rate-limit sur les alertes — un partenaire envoie 100 poison messages par minute, l'ops reçoit 100 SMS.
Patterns liés
- Dead-Letter Queue : variantes — pattern parent dont poison message est une variante.
- Dead Letter Channel — la version EIP canonique.
- Circuit Breaker — utile pour protéger le sandbox d'analyse.
- Bulkhead — isoler le sandbox des autres consumers du hub.
- Retry & Backoff — fournit le counter qui sert à la détection poison.
Sources
- Nygard M. — Release It! Design and Deploy Production-Ready Software, 2e éd. Pragmatic Bookshelf 2018, chap. 4 (« Stability Antipatterns » — Cascading Failures).
- Microsoft — Handling Poison Messages, Azure Service Bus documentation. learn.microsoft.com
- Apache Kafka — Kafka Streams Error Handling Documentation. kafka.apache.org
- AWS — Amazon SQS Best Practices: Handling Poison Pills. docs.aws.amazon.com
- OWASP — ReDoS: Regular Expression Denial of Service. Cause fréquente de poison message via payload partenaire. owasp.org