La Taverne "The Falling Whale" a désormais sa volière en place.
Tout fonctionne à merveille tant que le message est correctement reçu et que la cuisine tourne rond.
Mais que se passe-t-il si le cuisinier commence à préparer un plat et que le fourneau tombe en panne ? Ou si un ingrédient indispensable vient à manquer ? Ou encore si une erreur temporaire empêche le traitement du message ?
Si le cuisinier abandonne immédiatement, le message est perdu. S'il reste bloqué indéfiniment dessus, les corbeaux suivants s'accumulent sur le perchoir et finissent par paralyser tout le service.
C'est là qu'interviennent nos deux filets de secours : le Retry et la DLQ (Dead Letter Queue).
La persévérance du cuisinier : le Retry
Certaines erreurs ne sont que passagères.
Le fourneau est peut-être éteint quelques secondes le temps de remettre une bûche. Le commis a peut-être bloqué l'accès aux ustensiles pendant un court instant. Une ressource externe peut également être momentanément indisponible.
Dans ce genre de situation, il serait dommage de considérer immédiatement le message comme définitivement perdu.
Plutôt que de déclarer forfait, le cuisinier peut reprendre son souffle et retenter la préparation quelques instants plus tard.
C'est le mécanisme de Retry.
Avec Quarkus et l'extension MicroProfile Fault Tolerance, cette persévérance s'exprime simplement grâce à l'annotation @Retry.
On peut notamment définir :
maxRetries: le nombre de tentatives supplémentaires ;delay: le délai d'attente entre deux tentatives.
Par exemple :
@Incoming("raven-inbound")
@Retry(maxRetries = 3, delay = 1000)
public void onRavenLanding(RavenMessage message) {
LOG.infof("Un corbeau se pose : message de %s -> \"%s\"",
message.sender(), message.content());
// Simulation d'une erreur temporaire ou définitive
if (message.content().contains("fail")
|| message.content().contains("erreur")) {
throw new RuntimeException("Échec simulé du traitement !");
}
}
Avec cette configuration, le traitement est tenté une première fois, puis au maximum trois fois supplémentaires, avec une seconde d'attente entre chaque tentative.
On obtient donc au maximum quatre passages du même message dans notre méthode :
Si le problème était temporaire, l'une des tentatives finit par réussir et le message poursuit normalement son chemin.
Mais si le parchemin est définitivement problématique, ou si la cuisine est réellement hors service, insister indéfiniment ne sert à rien.
Il faut alors sortir le message du circuit normal.
La cage aux corbeaux égarés : la Dead Letter Queue
Lorsque toutes les tentatives ont échoué, notre message devient ce que l'on appelle parfois une poison pill : un message qui provoque systématiquement une erreur lorsqu'il est traité.
Si le cuisinier s'obstine à le reprendre encore et encore, il risque de bloquer la lecture de la partition et d'empêcher les messages suivants de progresser.
Il faut donc isoler le corbeau fautif.
C'est précisément le rôle de la Dead Letter Queue, ou DLQ.
Avec Reactive Messaging, le consumer peut signaler que le traitement d'un message a définitivement échoué en le nackant (negative acknowledgement).
La stratégie dead-letter-queue prend alors le relais : le message est envoyé vers un topic Kafka dédié à la collecte des messages en échec.
Dans notre taverne, nous allons utiliser :
raven-roost-dlq
La situation devient alors :
Le message n'est pas simplement supprimé.
Il est recopié dans le topic de Dead Letter Queue, accompagné d'informations permettant notamment de comprendre l'origine de l'échec. Les métadonnées Kafka permettent ainsi de retrouver le topic, la partition et l'offset d'origine, ainsi que la cause de l'erreur.
Une fois le message pris en charge par la DLQ :
- l'offset du message d'origine peut être validé ;
- le consumer peut poursuivre la lecture de la partition ;
- le message fautif reste disponible dans la DLQ pour être analysé ou rejoué ultérieurement.
Le corbeau est donc sorti de la volière principale, mais son parchemin n'est pas perdu.
Un détail important : le Retry ne répare pas tout
Il faut toutefois distinguer deux types d'erreurs.
Dans notre exemple, Kafka a bien réussi à recevoir le message et Quarkus a réussi à le transformer en RavenMessage. C'est le traitement métier qui échoue ensuite.
C'est précisément dans ce cas que @Retry intervient.
À l'inverse, si Kafka reçoit un message impossible à désérialiser en RavenMessage, le problème intervient avant même l'appel à notre méthode onRavenLanding().
Notre @Retry ne peut donc pas relancer un traitement qui n'a jamais pu commencer.
Cette distinction est importante : le Retry présenté ici concerne les erreurs survenant pendant le traitement du message, tandis que les erreurs de désérialisation nécessitent une stratégie spécifique.
Configurer le filet de secours avec Quarkus
Pour équiper notre colombier de ce double dispositif, nous devons faire évoluer notre projet.
Ajouter la persévérance
Nous commençons par ajouter l'extension MicroProfile Fault Tolerance :
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-fault-tolerance</artifactId>
</dependency>
Puis nous décorons notre méthode réceptrice avec @Retry :
@Incoming("raven-inbound")
@Retry(maxRetries = 3, delay = 1000)
public void onRavenLanding(RavenMessage message) {
LOG.infof("Un corbeau se pose : message de %s -> \"%s\"",
message.sender(), message.content());
// Simulation d'une erreur de traitement
if (message.content().contains("fail")
|| message.content().contains("erreur")) {
throw new RuntimeException("Échec simulé du traitement !");
}
}
Le code reste volontairement simple : si le message contient fail ou erreur, le traitement échoue.
Cela nous permet de provoquer facilement les différentes étapes du scénario.
Câbler la cage aux échecs
Il reste maintenant à indiquer à notre consumer quoi faire lorsqu'un message finit par être rejeté.
Dans application.properties :
# Stratégie en cas d'échec définitif : envoyer le message vers une DLQ
mp.messaging.incoming.raven-inbound.failure-strategy=dead-letter-queue
# Topic Kafka utilisé pour la Dead Letter Queue
mp.messaging.incoming.raven-inbound.dead-letter-queue.topic=raven-roost-dlq
# Sérialisation des messages envoyés vers la DLQ
mp.messaging.incoming.raven-inbound.dead-letter-queue.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.incoming.raven-inbound.dead-letter-queue.value.serializer=io.quarkus.kafka.client.serialization.ObjectMapperSerializer
Notre architecture dispose maintenant de deux circuits :
Le filet est en place.
Reste maintenant à vérifier qu'il fonctionne réellement.
Valider le filet de secours
Faire confiance à un filet de sécurité sans l'avoir testé serait une mauvaise idée.
Dans le premier article, nous utilisions un connecteur purement en mémoire pour réaliser des tests rapides et simples. C'était parfaitement adapté pour vérifier que notre application publiait et recevait correctement des messages.
Pour tester une stratégie comme la DLQ, la situation est différente.
Cette fois, nous voulons vérifier le comportement réel de l'intégration Kafka : NACK, failure strategy, publication dans le topic de Dead Letter Queue et progression de l'offset.
Nous allons donc exécuter un véritable test d'intégration Kafka grâce aux Dev Services de Quarkus, qui fournissent ici un broker Redpanda éphémère.
Notre test RavenRetryDlqTest suit un scénario très simple :
- nous envoyons un message contenant le texte
"erreur fatale"; - le consumer tente de le traiter ;
- le traitement échoue ;
@Retryeffectue trois nouvelles tentatives ;- le traitement échoue une dernière fois ;
- le message est
nack; - la stratégie DLQ l'envoie vers
raven-roost-dlq; - le test vérifie que le message est bien présent dans ce topic.
La cinématique attendue est donc :
Les logs permettent alors de suivre cette histoire directement :
[INFO] Un corbeau se pose : message de Tavernier -> "Ce message contient une erreur fatale"
[INFO] Un corbeau se pose : message de Tavernier -> "Ce message contient une erreur fatale"
[INFO] Un corbeau se pose : message de Tavernier -> "Ce message contient une erreur fatale"
[INFO] Un corbeau se pose : message de Tavernier -> "Ce message contient une erreur fatale"
[ERROR] The method RavenRoostListener#onRavenLanding has thrown an exception
[INFO] A message sent to channel `raven-inbound` has been nacked,
sending the record to a dead letter topic raven-roost-dlq
Quatre appels apparaissent dans les logs : l'essai initial, puis les trois retries.
Le corbeau récalcitrant finit ensuite dans sa cage.
Inspecter la cage avec Quarkus Dev UI
Une fois la DLQ opérationnelle, il faut bien entendu pouvoir vérifier ce qui s'y trouve.
Pour cet article, j'utilise Quarkus 3.34.6. Cette version fournit, avec l'extension quarkus-kafka-client, une intégration Kafka dans la Dev UI qui permet notamment de travailler avec les topics du broker.
En lançant l'application en mode développement, la Dev UI est disponible à l'adresse :
http://localhost:8080/q/dev-ui
L'endpoint Topics permet notamment de lister les topics Kafka présents sur le broker et d'en consulter les messages.
On peut ainsi retrouver notre petite cage :
raven-roost
raven-roost-dlq
et inspecter directement le contenu de raven-roost-dlq.
C'est particulièrement pratique en développement : inutile d'ajouter un consumer temporaire uniquement pour vérifier qu'un message a bien été envoyé dans la DLQ. La Dev UI nous permet de regarder directement ce qui se passe dans le broker.
Gérer la cage aux échecs au quotidien
En développement, la Dev UI est donc suffisante pour observer rapidement les topics et les messages qui y circulent.
En production, les besoins sont naturellement différents.
Une DLQ ne doit pas devenir une poubelle où les messages viennent mourir silencieusement.
Elle doit au contraire être surveillée.
On pourra notamment :
- surveiller l'apparition de messages dans la DLQ ;
- déclencher une alerte dès qu'elle n'est plus vide ;
- examiner les métadonnées du message pour comprendre l'origine de l'échec ;
- identifier si l'erreur vient des données ou du code ;
- corriger le problème ;
- puis rejouer les messages concernés.
Il est généralement préférable de ne pas faire consommer la DLQ en permanence par le même consumer que celui du flux principal. Son intérêt est justement de sortir temporairement les messages problématiques du circuit normal afin de permettre leur analyse.
Selon l'architecture retenue, le rejeu pourra ensuite être manuel ou automatisé.
L'idée est toujours la même : la DLQ n'est pas une destination finale, mais une zone d'isolement.
Et si l'erreur est temporaire... mais très longue ?
Notre exemple utilise un retry très simple :
@Retry(maxRetries = 3, delay = 1000)
Trois tentatives espacées d'une seconde suffisent pour illustrer le principe.
Dans une véritable application, les délais peuvent cependant être beaucoup plus importants.
Imaginez un service externe indisponible pendant cinq minutes. Bloquer notre consumer en attendant chaque retry n'est probablement pas la meilleure stratégie.
Kafka et Reactive Messaging permettent également de mettre en place des stratégies de retry reposant sur des topics intermédiaires, parfois appelés retry topics. Le message peut alors patienter dans un topic dédié avant d'être représenté au consumer.
Notre simple filet peut donc évoluer vers une véritable organisation de volière :
flowchart TD
A[raven-roost] --> B[Consumer]
B -->|erreur temporaire| C[raven-roost-retry]
C -->|attente| D[Consumer]
D -->|succès| E[ACK]
D -->|échec| F[DLQ]
Ce mécanisme dépasse toutefois le cadre de cet article. Pour l'instant, notre @Retry suffit largement à comprendre le principe.
En résumé
Le couple Retry & DLQ permet de concilier deux exigences essentielles d'une application événementielle.
Le Retry permet de faire face aux erreurs temporaires sans abandonner immédiatement un message.
La DLQ, elle, permet d'isoler les messages qui ne peuvent décidément pas être traités, afin qu'ils ne bloquent pas le reste de la volière.
Notre chaîne est désormais complète :
Le cuisinier peut donc se montrer persévérant lorsqu'un fourneau lui joue des tours, sans pour autant rester éternellement bloqué devant un parchemin impossible à traiter.
La volière est désormais capable d'encaisser les petites tempêtes sans interrompre tout le service de la taverne.
Et les corbeaux qui posent vraiment problème ?
Ils ont leur propre cage, bien au chaud, en attendant qu'un tavernier vienne comprendre ce qui leur est arrivé.
Tout le code relatif à cet article est consultable juste ici :
