Aller au contenu

La horde : des gobelins pilotés par une entité supérieure

Et si une IA pilotait le chaos ? Avec Goblin 0.4.0 et Dev MCP, confiez la horde à un agent autonome. Grâce au carnet d'ordres et au fichier RESILIENCE.md, l'IA éprouve vos défenses Quarkus et analyse les pannes. Le gobelin frappe, l'entité conclut, le développeur décide !

Une horde de gobelins attaque la taverne The Falling Whale
La horde de gobelins attaque la taverne

La dernière fois, on s'est quittés sur une promesse.

Goblin, l'extension Quarkus qui injecte des pannes dans une application pendant le développement, avait passé quatre soirées à malmener The Falling Whale, la taverne de démo de cette série. Les vieilles lois de la taverne avaient tenu. Je concluais : la prochaine étape, c'est de laisser le gobelin vérifier lui-même le résultat de ses assauts.
Déclarer « si j'injecte 2 s de latence sur les commandes, j'attends un 504 en moins de 1,6 s », et obtenir un verdict.

Cette promesse, je ne l'ai pas tenue.
Pas comme ça.

Le verdict existe bien aujourd'hui. Mais ce n'est pas le gobelin qui le rend. C'est une entité supérieure qui n'a jamais mis les pieds dans la taverne : une IA, qui prend la tête de la horde de gobelins avec, pour tout bagage, une adresse et un carnet d'ordres.

Cet article raconte pourquoi j'ai changé d'avis, ce que Goblin 0.4.0 donne à cette entité, et ce qui s'est passé le soir où je lui ai confié la horde.

Le jour où j'ai vu le monstre

Sur le papier, le verdict avait tout pour plaire. Le ticket était écrit, priorisé, au sommet de la roadmap : des règles déclaratives, évaluées par le moteur après chaque assaut, un pass ou un fail par règle. Juste derrière venait le mode CI, pour rejouer ces scénarios dans un pipeline, sans navigateur.

Et puis, un soir, en relisant cette roadmap, je me suis dit : je suis en train de créer un monstre.

Un moteur de règles, ce n'est pas une fonctionnalité de plus. C'est un second produit greffé sur le premier : un langage de règles, une corrélation de signaux, un format de rapport à maintenir. Et le mode CI contredisait tout ce que le gobelin défend depuis le début : le chaos se joue face à quelqu'un qui regarde, avec un auto-off armé. Un pipeline, par définition, tourne quand personne ne regarde. Les tests de l'article précédent restent possibles : un @QuarkusTest qui arme le gobelin pour vérifier un @Timeout est écrit et relu par un humain. Ce que j'ai refusé, c'est un mode qui lance des campagnes de chaos dans un pipeline et en tire seul un verdict.

Les deux tickets ont été fermés. La roadmap porte désormais une section qui dit ce que Goblin n'est pas, pour que la prochaine bonne idée ne rouvre pas le débat :

  • pas un outil de CI : le chaos appartient à quelqu'un qui regarde l'application ;
  • pas un moteur de règles : Goblin produit les signaux, l'humain ou l'agent conclut ;
  • pas un outil de chaos en production ou en staging.

Restait une question. Si le gobelin ne rend pas de verdict, qui le rend ?

Une première entité, à l'aveugle

L'idée de confier le verdict à une IA ne sort pas de nulle part. Elle vient d'une expérience faite avant même d'écrire une ligne de la 0.4.0.

J'avais donné à un agent IA la taverne de quarkus-goblin-demo et un simple AGENTS.md. Pas d'outil dédié, pas de mode d'emploi du gobelin. Il a quand même trouvé tout seul un défaut de résilience dans la démo.

Et au passage, il a trouvé deux bugs dans le gobelin lui-même :

  • le gobelin se réveillait tout seul : désactiver le chaos depuis la Dev UI ne survivait pas au rechargement suivant du dev mode. Corriger le code en pleine session réarmait le gobelin ;
  • un 503 qui mentait sur son contenu : les assauts HTTP status et dégradation de dépendance renvoyaient un corps en texte brut, annoncé en application/json.

Mais le plus instructif, c'est ce qu'il a dû improviser. Pour comprendre ce que chaque couche signifiait pour Fault Tolerance, il a lu le code source. Pour savoir quand reculer, il a deviné. Et il s'est trompé en observant le disjoncteur, parce que l'information nécessaire n'était nulle part.

Tout ce qu'il avait improvisé, c'était exactement ce que Goblin aurait dû lui dire. La 0.4.0 est née de cette liste.

Confier la horde : Dev MCP

Quarkus embarque un serveur MCP en dev mode, Dev MCP. Une seule ligne l'allume :

quarkus.dev-mcp.enabled=true

Il répond sur http://localhost:8080/q/dev-mcp. Depuis la 0.4.0, chaque méthode JSON-RPC du gobelin y devient un outil, nommé quarkus-goblin_<méthode>. Ce sont les mêmes méthodes que celles du tableau de bord de la Dev UI : l'entité tient les mêmes manettes que le développeur.

Toutes ne lui sont pas confiées d'emblée. Le partage suit une règle simple : observer ou arrêter, c'est libre ; armer ou modifier, c'est sur autorisation.

Ce que l'entité peut ordonner seule Ce qu'elle doit demander au développeur
Lire l'état et la configuration : getStatus, getConfig Armer le chaos : setActive, applyConfig, setProfile
Lire ce qui s'est passé : getHistory, getCounters, getMarkdownReport Régler les assauts : les bascules et les réglages de latence, d'exception, de statut HTTP
Poser un filet : startAutoOff, cancelAutoOff Rejouer ou enregistrer une expérience : loadScenario, saveScenario, deleteScenario
Tout arrêter : disableAll Effacer les preuves : resetCounters, clearHistory
Lister les scénarios enregistrés : listScenarios
Page Tools de Dev MCP filtrée sur goblin : outils de lecture et d'arrêt activés, outils d'armement désactivés
La page Tools de Dev MCP filtrée sur goblin : les outils de lecture et d'arrêt activés, ceux qui arment le chaos désactivés

C'est exactement ce que montre la page des outils de Dev MCP, filtrée sur goblin : les interrupteurs verts sont ceux que l'entité reçoit d'office, les gris attendent le développeur.

Un agent fraîchement connecté peut donc toujours observer et toujours arrêter. Il ne peut pas casser la taverne avant qu'un humain l'y autorise. Et les descriptions des outils en lecture lui disent que les autres existent, pour qu'il ne conclue pas que le gobelin ne sait rien armer.

L'autorisation se donne dans la Dev UI. Le chemin n'est pas évident, et je l'ai moi-même cherché : Paramètres (l'engrenage en haut à droite), onglet Dev MCP, puis la page Tools. Quarkus retient le choix dans ~/.quarkus/dev-mcp.properties.
Attention : ce fichier est dans votre répertoire personnel, et le choix vaut pour toutes vos applications Quarkus qui utilisent le gobelin.
Pensez à refermer la porte après l'expérience.

Les ordres de la horde

Des manettes ne suffisent pas. Le premier agent l'avait montré : sans consignes, il lit le code, il devine, et il se trompe. La 0.4.0 laisse donc à l'entité deux documents sur le comptoir, deux ressources Dev MCP qu'elle trouve seule en listant ce que le serveur expose.
Et au cas où elle ne penserait pas à les chercher, la description de getStatus, le premier outil qu'un agent appelle, lui dit de les lire avant toute expérience.

La page Resources de Dev MCP : agentPlaybook et resilienceInventory, activées par défaut
La page Resources de Dev MCP : agentPlaybook et resilienceInventory, activées par défaut

Côté entité, un resources/list sur la taverne répond ceci, pour la partie gobelin :

{
  "resources": [
    {
      "uri": "quarkus://resource/build-time/quarkus-goblin_agentPlaybook",
      "name": "quarkus-goblin_agentPlaybook",
      "description": "Read this first, before any Goblin chaos experiment: the Goblin agent playbook, in Markdown. Safety rules, what each chaos layer means for MicroProfile Fault Tolerance, how to observe an experiment (including a circuit breaker's state), the hypothesis / assault / observation / conclusion loop, and the shape of the report to write.",
      "mimeType": "application/json"
    },
    {
      "uri": "quarkus://resource/build-time/quarkus-goblin_resilienceInventory",
      "name": "quarkus-goblin_resilienceInventory",
      "description": "Where this application believes it is protected: every method guarded by a MicroProfile Fault Tolerance annotation (...)",
      "mimeType": "application/json"
    }
  ]
}

La description est écrite pour l'entité, pas pour un humain : « Read this first ». C'est elle qui décide si un agent ouvre le carnet ou passe à côté.

Le playbook

quarkus-goblin_agentPlaybook est un manuel court et impératif, livré avec l'extension. Il commence par sept règles de sécurité :

  1. Dev mode uniquement.
  2. L'auto-off d'abord, avant d'armer quoi que ce soit.
  3. Noter la configuration de départ, et la remettre à la fin.
  4. Une seule variable à la fois : un assaut, une couche, une hypothèse.
  5. N'armer que les couches que l'application possède.
  6. Ne jamais contourner un outil désactivé : ni en éditant application.properties, ni par un autre outil qui modifie la configuration. On demande au développeur.
  7. Garder les preuves : lire l'historique avant de l'effacer.

Vient ensuite ce que le premier agent a dû deviner : ce que chaque couche signifie pour Fault Tolerance, le piège de la porte d'entrée de l'article précédent, et surtout comment observer. Les métriques ft_* de SmallRye Fault Tolerance y sont nommées exactement, vérifiées sur une application qui tourne. Pour savoir si un disjoncteur est ouvert, par exemple :

ft_circuitbreaker_state_current{method="fr.eletutour.tavern.service.MerchantService.commander",state="open"} 1.0

Il se termine par la boucle de travail (hypothèse, assaut, observation, conclusion, remise en état) et par la forme du rapport attendu. Le même texte est publié dans la documentation : l'agent et l'humain lisent le même carnet.

L'inventaire de résilience

quarkus-goblin_resilienceInventory répond à une question simple : où la taverne croit-elle être protégée ? Au build, le gobelin indexe les annotations @Timeout, @Retry, @CircuitBreaker, @Fallback, @Bulkhead et @RateLimit des classes de l'application. Voici l'entrée de la guilde des marchands, telle que l'entité la lit :

{
  "class" : "fr.eletutour.tavern.service.MerchantService",
  "method" : "commander",
  "metricMethodTag" : "fr.eletutour.tavern.service.MerchantService.commander",
  "annotations" : {
    "Timeout" : { "value" : 2000, "unit" : "MILLIS" },
    "Retry" : { "maxRetries" : 2, "delay" : 200, "delayUnit" : "MILLIS", "jitter" : 200, "jitterDelayUnit" : "MILLIS",
                "abortOn" : [ "org.eclipse.microprofile.faulttolerance.exceptions.CircuitBreakerOpenException" ], ... },
    "CircuitBreaker" : { "requestVolumeThreshold" : 6, "failureRatio" : 0.5, "delay" : 10000, "delayUnit" : "MILLIS", ... },
    "Fallback" : { "fallbackMethod" : "marchandAbsent", ... }
  },
  "reachableBy" : [ "SERVICE" ]
}

Trois détails comptent. Les paramètres non déclarés y figurent avec leur valeur par défaut : deviner un requestVolumeThreshold est le plus sûr moyen de rater une expérience. metricMethodTag donne le tag exact à filtrer dans /q/metrics. Et reachableBy liste les couches dont on sait qu'elles atteignent la méthode à l'intérieur de Fault Tolerance.

Ce que le gobelin ne peut pas savoir

L'inventaire dit où sont les défenses. Il ne dit pas ce que la taverne promet : son temps de service, ce qu'un client doit voir quand la cave tombe, ce qu'on n'a pas le droit de toucher. Seule l'équipe le sait. La documentation fournit donc un modèle, RESILIENCE.md, à copier à la racine du projet. Le playbook dit à l'agent de le lire, et de demander au développeur quand il n'existe pas.

Lors de l'épreuve, la taverne n'en avait pas. Ses promesses existaient pourtant, dans la section « Les promesses de la taverne » de son AGENTS.md, que l'entité n'avait pas le droit de lire. Elle les a reconstruites seule depuis l'OpenAPI, et elle est retombée sur les bonnes : l'ardoise, le marchand absent, la commande servie en 1,5 s.

Depuis, la taverne a son RESILIENCE.md. Il reprend ces promesses au format du modèle, et y ajoute ce que l'épreuve a appris. En voici le cœur :

## Les dépendances et ce qui les protège

| Dépendance | Appelée depuis | Protection | Ce que voit le client quand elle tombe |
|---|---|---|---|
| La cave (PostgreSQL) | `GrimoireService.consulterLeGrimoire` | `@Retry(maxRetries = 2, delay = 100)` + `@Fallback(lireLArdoise)` | la carte en `200`, depuis l'ardoise |
| La cave (PostgreSQL) | `OrderService.passerCommande` | `@Timeout(1500)` + `@RateLimit(60 / 10 s)`, sans repli | un `5xx` en `problem+json`, aventurier non débité |
| La guilde des marchands (REST Client) | `MerchantService.commander` | `@Timeout(2000)` + `@Retry` + `@CircuitBreaker(6, 0.5, 10 s)` + `@Fallback(marchandAbsent)` | `200` avec `status: MARCHAND_ABSENT` et `delivered: 0` |

## Les promesses à vérifier

| # | Quand | Alors |
|---|---|---|
| P1 | la cave ne répond plus | `GET /grimoire/recettes` répond `200` depuis l'ardoise |
| P2 | la cuisine dépasse 1,5 s | `POST /commandes` répond `504` en 1,5 s environ, et rien n'est débité |
| P5 | la guilde reste injoignable | le disjoncteur s'ouvre, et le repli répond en moins de 300 ms, sans nouvelle tentative, pendant 10 s |
| P6 | plus de 60 commandes en 10 s | le comptoir répond `429` avec `Retry-After: 10`, sans erreur serveur |

## Les points ouverts

- `CellarService.reapprovisionner` n'a aucune protection : une panne à cet endroit donne un `500` au client, avant même d'atteindre la guilde.
- Le pire cas d'un réapprovisionnement quand la guilde traîne est de 6,5 s : trois tentatives de 2 s, sans budget de temps global. Acceptable ou pas ?

## Hors limites

- La salle, la main courante, l'observabilité et le bootstrap : exclus du ciblage du gobelin, ne les visez pas.
- La main courante est conservée d'un redémarrage à l'autre : ne purgez pas les incidents ouverts par une expérience.

La section des points ouverts est née des questions de l'entité. Ce ne sont pas des promesses, ce sont des décisions qui attendent le développeur. La prochaine entité qui passera par la taverne saura qu'elles sont connues, et n'aura pas à les redécouvrir.

L'épreuve : une entité qui ne sait rien

Un carnet, ça s'éprouve aussi. Le critère que je m'étais fixé tenait en une phrase : un agent qui n'a jamais vu la taverne doit mener une expérience de bout en bout, sans lire le code du gobelin.

J'ai donc lancé un agent neuf, sans aucun contexte. Il ne connaissait ni cette série d'articles, ni le code de Goblin, ni l'AGENTS.md de la démo. Il a reçu en tout et pour tout :

  • une adresse, http://localhost:8080/q/dev-mcp ;
  • une mission : vérifier si les mécanismes de résilience de cette application tiennent vraiment, et rendre un rapport ;
  • quatre contraintes : travailler uniquement en HTTP, ne lire aucun fichier sur le disque, s'arrêter et demander si quelque chose ne peut être accordé que par un humain, et laisser l'application dans l'état où il l'a trouvée.

En face de lui, la taverne habituelle, avec le gobelin de la 0.4.0 et sa configuration de tous les soirs : 50 à 400 ms de latence sur un quart des requêtes, à la porte d'entrée. Dans la Dev UI, je n'avais activé aucun outil d'armement. Volontairement : c'était le premier test.

Premier soir : l'entité s'arrête à la porte

L'entité a commencé comme on l'espère d'un bon chef de horde : en regardant le terrain. Elle a listé les ressources du serveur, trouvé le playbook et l'inventaire, et les a lus avant toute autre chose. Elle a ensuite lu l'état du gobelin, sa configuration, les compteurs, l'OpenAPI de la taverne et /q/metrics.

Le premier outil, getStatus, lui résume la soirée :

{
  "active" : true,
  "autoOffRemainingMs" : 0,
  "profile" : "NONE",
  "layers" : [ "HTTP_OUT", "HTTP_IN" ],
  "availableLayers" : [ "DATABASE", "SERVICE", "HTTP_OUT", "HTTP_IN" ],
  "latencyEnabled" : true,
  "exceptionEnabled" : false,
  ...
  "level" : 25
}

Tout y est pour la suite : le gobelin est actif, aucun auto-off n'est armé, et la taverne possède quatre couches. Pas de MESSAGING ici, la taverne n'a pas de file de messages.

De l'OpenAPI, elle a tiré seule les promesses de la taverne, faute de RESILIENCE.md : la carte servie depuis l'ardoise quand la cave tombe, un marchand absent plutôt qu'une erreur, une commande servie en moins de 1,5 s. Puis elle a croisé chaque promesse avec la méthode protégée de l'inventaire.

Et elle s'est arrêtée là. Pour éprouver ces défenses, il fallait armer la couche Service, donc applyConfig et setActive. Ni l'un ni l'autre n'était dans sa liste d'outils. Son rapport le dit sans détour :

Je n'ai pas pu vérifier un seul mécanisme de résilience. Je n'ai rien modifié.

Et appeler quand même un outil désactivé ne contourne rien. Le serveur ne le connaît tout simplement pas :

{
  "id" : 5,
  "error" : {
    "code" : -32601,
    "message" : "Method [quarkus-goblin_applyConfig] not found"
  },
  "jsonrpc" : "2.0"
}

Pas de contournement, pas de réglage de propriété en douce. Une demande précise : applyConfig et setActive au minimum, avec la raison. applyConfig est le seul outil qui choisit les couches, et il servira aussi à remettre la configuration de départ à la fin. Elle a même posé des questions que je n'attendais pas :

  • le chaos tourne déjà, sans auto-off : est-ce voulu ?
  • puis-je envoyer du trafic qui modifie des données, de vraies commandes et de vrais réapprovisionnements ?
  • les commandes n'ont pas de @Fallback : est-ce volontaire ?

J'ai vérifié derrière elle : configuration identique, fichier d'état identique. Elle avait vraiment laissé la taverne comme elle l'avait trouvée.

Le contresens

Une de ses hypothèses était fausse, et c'était la faute du carnet. L'inventaire donnait reachableBy: [SERVICE] pour le grimoire. Elle en a conclu que la couche Database n'atteignait pas consulterLeGrimoire, donc qu'une vraie panne de la cave ne déclencherait peut-être pas l'ardoise.

Or reachableBy ne liste que ce qu'un index d'annotations peut prouver. Il ne peut pas savoir qu'une méthode ouvre une connexion JDBC, alors Database n'y figure jamais. Le playbook le disait, mais dans le tableau des couches, pas à l'endroit où l'entité lit l'inventaire. La phrase a changé de place avant le deuxième soir.

Deuxième soir : la horde est lâchée

Les deux outils activés, j'ai renvoyé l'entité à la taverne avec ses réponses : remettre le chaos comme au départ, trafic autorisé sans excès, pas de RESILIENCE.md, donc l'OpenAPI comme promesses.

Elle a d'abord tout désarmé pour mesurer la taverne au calme : une commande servie en 0,18 s, un réapprovisionnement livré en 0,15 s. Puis, avant chaque assaut, un auto-off de cinq minutes. Une couche à la fois, un type d'assaut à la fois.

Expérience Couche et assaut Ce que la taverne a fait Verdict
Le grimoire Service, exception, niveau 100 % 3 appels, 3 cartes servies en 200 depuis l'ardoise. Le gobelin a frappé 9 fois : la tentative et les deux nouvelles tentatives de chaque appel Tient
Le grimoire Service, exception, niveau 50 % 8 appels, 8 réponses 200 : 4 sans encombre, 3 sauvées par le @Retry, 1 par l'ardoise Tient
Le grimoire Database, exception, niveau 100 % 3 appels, 3 cartes depuis l'ardoise, 9 assauts sur la connexion JDBC Tient
La guilde Service, exception, niveau 100 % 8 réapprovisionnements en 500 en 5 ms. Le gobelin a frappé l'appelant, avant la méthode protégée Non concluant
La guilde Outbound HTTP, exception côté client, niveau 100 % Appels 1 et 2 : marchand absent en 0,58 s. Appels 3 à 6 : marchand absent en 13 à 17 ms, disjoncteur ouvert Tient
La guilde Outbound HTTP, latence de 2 500 à 3 000 ms Marchand absent en 6,53 s : trois tentatives coupées chacune à 2 s. L'appel suivant : 2,32 s, le disjoncteur s'était rouvert Tient
Le comptoir Service, latence de 1 800 à 2 000 ms 504 en 1,52 s, TimeoutException, aucune pièce d'or débitée Tient
Le comptoir Database, latence de 1 800 à 2 000 ms 504 en 1,53 s, rien d'écrit Tient
Le comptoir Aucun assaut, 66 commandes en 3 s 60 acceptées par le @RateLimit, 6 refusées en 429 Tient

Chaque verdict s'appuie sur les métriques que le carnet lui avait appris à lire. Pour le grimoire à 100 % : fallback="applied" trois fois, retryResult="maxRetriesReached" trois fois. Pour la guilde : ft_circuitbreaker_opened_total à 1, puis 10,02 s passées en état ouvert, pour un délai déclaré de 10 s.

J'ai rejoué ces expériences derrière elle, sur la même taverne et avec le trafic de scripts/trafic.sh, pour en garder les traces. Les captures et les réponses qui suivent viennent de ce second passage : les compteurs y sont plus gros, puisque les clients de la taverne continuaient de commander.

Le grimoire et son ardoise

Couche Service, exceptions à 100 %. Dans la salle, la carte du jour passe à l'ardoise pendant que le comptoir perd ses commandes : passerCommande n'a pas de repli, lui.

La salle pendant l'assaut : la carte servie depuis l'ardoise, les commandes perdues au comptoir
La salle pendant l'assaut : la carte servie depuis l'ardoise, les commandes perdues au comptoir

GET /grimoire/recettes répond toujours 200, en 0,27 s, avec la carte complète. Les métriques racontent le détour :

ft_retry_calls_total{method="fr.eletutour.tavern.service.GrimoireService.consulterLeGrimoire",retried="true",retryResult="maxRetriesReached"} 4.0
ft_invocations_total{fallback="applied",method="fr.eletutour.tavern.service.GrimoireService.consulterLeGrimoire",result="valueReturned"} 4.0

Et l'historique du gobelin, ce que lit l'entité avec getHistory, montre où il a frappé :

{
  "method": "fr.eletutour.tavern.service.GrimoireService.consulterLeGrimoire",
  "type": "exception",
  "timestamp": 1791098654480,
  "latencyMs": 0,
  "source": "service",
  "config": "exception enabled (java.lang.RuntimeException: \"Goblin chaos: simulated exception\")"
}

Le piège où je suis tombé aussi

La quatrième ligne du tableau des expériences, celle marquée « Non concluant », mérite qu'on s'y arrête. Pour éprouver la guilde, l'entité a d'abord armé la couche Service. Le gobelin n'a jamais atteint MerchantService.commander : il a frappé CellarService.reapprovisionner, le service qui l'appelle et qui n'a aucune protection.

Le client, lui, reçoit un 500 en 3 ms, sans le moindre marchand absent :

{
  "type": "about:blank",
  "title": "Erreur interne du serveur",
  "status": 500,
  "detail": "Une erreur inattendue est survenue lors du traitement de votre requête.",
  "instance": "http://localhost:8080/cave/stocks/1/reapprovisionnement",
  "traceId": "b4159281bc3381ddee079316fc3eadfb"
}

Et l'historique ne laisse aucun doute sur la victime :

{
  "method": "fr.eletutour.tavern.service.CellarService.reapprovisionner",
  "type": "exception",
  "source": "service",
  ...
}

C'est la règle que j'écrivais dans l'article précédent : seul l'appel de bean le plus extérieur d'une requête est attaqué. J'y étais tombé moi-même. L'entité aussi, parce que le carnet ne le disait pas.

La différence, c'est la suite. Elle a classé l'expérience « non concluante, mauvaise couche », au lieu de conclure que la guilde ne tenait pas. Puis elle a cherché une faute qui naît à l'intérieur de la méthode : l'appel sortant du REST Client. C'est la bonne couche, et c'est elle qui a fait tomber le disjoncteur.

Le disjoncteur qui ne fait plus attendre

Les appels 3 à 6 ont répondu en 13 à 17 ms. Aucun nouvel assaut dans l'historique : le disjoncteur refusait sans même laisser partir le coursier. Le @Retry, lui, s'arrêtait net sur le refus, avec retryResult="exceptionNotRetryable".

La porte de derrière : disjoncteur ouvert, le marchand n'est pas venu
La porte de derrière : disjoncteur ouvert, le marchand n'est pas venu

Sur mon second passage, le premier réapprovisionnement a pris 0,37 s, les sept suivants entre 6 et 10 ms. Tous ont reçu la même réponse, sans une erreur :

{
  "stock": {
    "ingredientId": 3,
    "ingredientName": "Eau de source elfique",
    "unit": "litre",
    "quantity": 103
  },
  "delivered": 0,
  "status": "MARCHAND_ABSENT",
  "message": "La guilde des marchands n'a pas répondu, réessayez plus tard."
}

Dans /q/metrics, avec le tag que donnait l'inventaire :

ft_circuitbreaker_state_current{method="fr.eletutour.tavern.service.MerchantService.commander",state="open"} 1.0
ft_circuitbreaker_opened_total{method="fr.eletutour.tavern.service.MerchantService.commander"} 1.0
ft_circuitbreaker_calls_total{circuitBreakerResult="circuitBreakerOpen",method="fr.eletutour.tavern.service.MerchantService.commander"} 7.0
ft_retry_calls_total{method="fr.eletutour.tavern.service.MerchantService.commander",retried="false",retryResult="exceptionNotRetryable"} 7.0
ft_invocations_total{fallback="applied",method="fr.eletutour.tavern.service.MerchantService.commander",result="valueReturned"} 8.0

Sept refus du disjoncteur, sept arrêts nets du @Retry, huit replis servis.

C'est l'abortOn ajouté au quatrième soir de l'article précédent, celui qui avait fait passer les refus de 394 à 5 ms. L'entité n'en savait rien. Elle a simplement constaté que la correction tenait toujours.

Le comptoir coupe, puis refuse

Couche Service, latence de 1 800 à 2 000 ms : la commande s'arrête à 1,52 s, sur le plafond du @Timeout.

{
  "type": "urn:falling-whale:problem:fault-tolerance",
  "title": "Service trop lent",
  "status": 504,
  "detail": "La cuisine n'a pas servi la commande à temps.",
  "instance": "http://localhost:8080/commandes",
  "faultTolerance": "TimeoutException",
  "traceId": "e60a1c87a51023f32b28c2f99ca12916"
}
ft_timeout_calls_total{method="fr.eletutour.tavern.service.OrderService.passerCommande",timedOut="true"} 1.0

Puis, sans aucun assaut, une rafale de 70 commandes simultanées. Le @RateLimit laisse passer sa soixantaine et renvoie le reste :

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 10

{
  "type": "urn:falling-whale:problem:fault-tolerance",
  "title": "Trop de commandes",
  "status": 429,
  "detail": "Holà l'ami ! Laisse-moi le temps de tirer ta bière, le tonneau n'est pas infini !",
  "faultTolerance": "RateLimitException",
  ...
}

Remettre la taverne en ordre

Avant de partir, elle a fait passer un dernier réapprovisionnement pour refermer le disjoncteur. Elle a réappliqué la configuration notée au départ, réactivé le chaos et annulé l'auto-off. J'ai comparé derrière elle : configuration identique, fichier d'état identique, disjoncteur fermé.

Elle n'a pas effacé l'historique : 44 assauts, gardés comme preuves. Et elle a listé ce qu'elle ne pouvait pas défaire : une commande et quelques livraisons en base, et trois incidents dans la main courante, qui, elle, survit aux redémarrages.

Le rapport de l'entité

À la fin de la soirée, l'entité a rendu son rapport, dans la forme que le playbook lui demandait : l'état de départ, chaque appel qui a changé quelque chose, un verdict par méthode protégée, ce qu'elle n'a pas pu faire, et ses questions. Il est en anglais, la langue de sa consigne. En voici des extraits : j'ai coupé et allégé la mise en forme, sans changer un mot.

## Report: Fault Tolerance check on "The Falling Whale" (Goblin chaos, Dev MCP, HTTP only)

### Bottom line
All three guarded methods behaved as declared in every experiment I could run. The Goblin
configuration is back exactly as I found it. The circuit breaker is closed again. The
application's own data and incident log keep traces of the experiments (listed at the end).

### Starting state (recorded before any change)
- Status: active: true, autoOffRemainingMs: 0, profile NONE.
- Assault: latency on, 50–400 ms. Every other toggle off, client-side ones included.
- Layers and level: HTTP_IN + HTTP_OUT, level 25.
[...]

### Observations and verdict per guarded method

**2. MerchantService.commander: HELD, exercised through HTTP_OUT**
- E3a, SERVICE exception at level 100: inconclusive, wrong layer.
  The fault fired on the caller, the unguarded CellarService.reapprovisionner, before
  commander was ever reached. All 8 calls returned 500 in about 5 ms.
- E3, HTTP_OUT client exception at level 100: held.
  Calls 1 and 2: MARCHAND_ABSENT (200) in about 0.58 s each.
  Calls 3–6: MARCHAND_ABSENT in 13–17 ms, with no new rest-client assault in the history.
  The breaker was open and rejecting calls without running them.
- Recovery: held. ft_circuitbreaker_state_total_seconds{open} came to 10.02 s, matching
  the 10 s delay.
[...]

**3. OrderService.passerCommande: HELD as declared**
- No fallback: confirmed. On a timeout the client gets a clean, fast 504 problem+json
  rather than a degraded answer. A slow order fails; it is not served.
- DATABASE reach: confirmed, for both this method (E4b) and the menu (E2).

### What I left changed, and could not undo with the tools granted
- Assault history and counters: 44 assaults (exception 38, latency 6; service 24,
  database 11, rest-client 9).
- Incident log: 3 incidents, all closed. The OpenAPI says this log "survives restarts",
  so these records will remain after a restart.

### Questions for the developer
1. Should failures in CellarService.reapprovisionner (and other unguarded beans and
   endpoints such as GET /grimoire/recettes/{id}) be protected, or is a 500 acceptable there?
2. Is 6.5 s an acceptable worst case for a restock while the guild is slow?
3. Should the 429 response carry a Retry-After header?
4. Is it OK that the 3 experiment incidents stay in the persisted incident log?

Trois choses méritent d'être relevées. Le rapport commence par l'état de départ, avant même la première expérience. Il qualifie son propre échec, « inconclusive, wrong layer », au lieu de le cacher ou de le transformer en défaut de la taverne. Et il dit ce qu'il laisse derrière lui, y compris ce qu'il ne pouvait pas défaire.

Ce que l'entité nous a appris

L'épreuve visait le carnet autant que la taverne. Les deux soirs ont chacun corrigé Goblin avant la sortie de la 0.4.0 :

Ce que l'entité a révélé Ce qui a changé dans Goblin
Elle a lu l'absence de Database dans reachableBy comme « inaccessible » Le playbook et la description de l'inventaire disent que Database n'y figure jamais
Elle a armé Service pour la guilde, et frappé l'appelant Le playbook dit que Service ne frappe que le premier bean de la requête, et comment s'y prendre autrement
Les outils l'auraient envoyée chercher un onglet introuvable Les descriptions donnent le chemin complet vers la page des outils
Les outils de configuration de Quarkus offraient un détour La règle 6 interdit d'armer le gobelin par un autre outil

Elle a aussi rendu ce qu'on attend d'un bon rapport : des questions sur la taverne elle-même, que ni les tests ni moi n'avions posées.

  • CellarService.reapprovisionner n'a aucune protection. Une panne à cet endroit donne un 500 au client, avant même d'atteindre la guilde.
  • Six secondes et demie, c'est long. Trois tentatives de 2 s, sans budget de temps global, c'est le pire cas d'un réapprovisionnement quand la guilde traîne.
  • Le 429 du comptoir porte-t-il un Retry-After ? Elle ne l'a pas vérifié, et le signale. J'ai regardé en rejouant la rafale : oui, Retry-After: 10, la fenêtre du @RateLimit.
  • Les commandes n'ont pas de repli, et c'est assumé. Le client reçoit un 504 propre en 1,53 s, au lieu d'attendre. Le post-mortem de l'article précédent les désignait déjà comme le prochain chantier.

Une de ses suggestions est restée dans le tiroir : que l'inventaire détecte l'appel REST caché dans commander, pour annoncer Outbound HTTP. Il faudrait analyser le graphe d'appels de l'application. C'est exactement le genre de fonctionnalité qui ferait grossir le monstre. Le carnet suffit : l'entité a trouvé la bonne couche toute seule.

Et pour être complet, elle n'a pas été parfaite. Je lui avais interdit de lire le disque. Elle n'a rien lu, mais elle y a écrit : son rapport, enregistré dans un fichier de son espace de travail. Rien de grave, hors du projet, et pas une infraction à la lettre. À l'esprit, si. Mais une consigne n'est pas une barrière, et c'est pour ça que les outils d'armement restent fermés tant qu'un humain ne les ouvre pas.

Ce qui reste au développeur

L'entité commande la horde. Elle ne commande pas la taverne. Trois choses restent entre les mains d'un humain, et c'est voulu :

  • ouvrir et refermer la porte : activer les outils d'armement pour l'expérience, puis les désactiver ;
  • écrire les promesses : le RESILIENCE.md dit ce que la taverne garantit, et ce qu'on n'a pas le droit de toucher ;
  • trancher : six secondes et demie pour un réapprovisionnement, c'est acceptable ou pas ? L'entité pose la question. Elle ne décide pas à votre place.

Pour essayer sur votre propre application, trois éléments suffisent : la dépendance, Dev MCP, et un agent connecté à /q/dev-mcp.

<dependency>
  <groupId>io.quarkiverse.goblin</groupId>
  <artifactId>quarkus-goblin</artifactId>
  <version>0.4.0</version>
</dependency>
quarkus.dev-mcp.enabled=true

Conclusion

J'avais promis un gobelin qui rendrait lui-même son verdict. J'ai livré autre chose, et je crois que c'est mieux.

Un moteur de règles aurait répondu à la question qu'on lui pose. L'entité a répondu à celle qu'on ne pensait pas à poser. Elle a vu que le réapprovisionnement n'a aucune protection avant la guilde. Elle a mesuré six secondes et demie là où personne n'avait fait le calcul. Et elle a trouvé quatre trous dans notre propre carnet, tous bouchés avant la sortie de la 0.4.0.

Le partage des rôles tient en une ligne : le gobelin frappe, l'entité conclut, le développeur décide.

Goblin, lui, reste ce qu'il est : un outil de dev mode, qui ne juge pas et ne tourne pas dans un pipeline. Tout le reste, c'est la horde, et celui qui la mène.


Le code de la démo et de l'AGENTS.md

GitHub - ErwanLT/quarkus-goblin-demo: Démo application for quarkus goblin
Démo application for quarkus goblin. Contribute to ErwanLT/quarkus-goblin-demo development by creating an account on GitHub.

Les sources de Goblin en open source

GitHub - quarkiverse/quarkus-goblin: Chaos engineering for Quarkus: inject latency, exceptions, forced HTTP statuses and simulated dependency degradation into REST endpoints during development
Chaos engineering for Quarkus: inject latency, exceptions, forced HTTP statuses and simulated dependency degradation into REST endpoints during development - quarkiverse/quarkus-goblin

Le carnet de la horde, tel que l'entité le lit : Agent playbook

Dernier