Un lundi soir de juin, je passe Granit en recette avant une mise en production. Une de ces étapes où je reprends le QA à la main, où je clique dans l’appli en staging comme le ferait un client, juste pour voir. Et là, le tableau de bord rame. Pas une page d’erreur, pas un écran blanc. Juste des squelettes gris qui tournent dans le vide, ces silhouettes de chargement qui ne se remplissent jamais. Dans la console, des FetchError : des requêtes parties, jamais revenues.
Je regarde l’état des services. Tout est vert. Le /health répond healthy, sûr de lui. Les conteneurs tournent. Aucune alerte n’a sonné de la nuit.
Et pourtant, depuis deux mois, Granit tournait sur une config que je n’avais jamais choisie.
Granit Golem, c’est ma plateforme de surveillance. Le cœur est en Rust, et je l’ai découpé en couches pour que la règle métier vive à un seul endroit. Ce soir-là, le problème n’était dans aucune de ces couches. Il était en amont de tout, dans la première chose que fait le programme au réveil : lire sa configuration.
Granit me sert aussi de terrain pour une méthode que j’assume : bâtir l’appli en entier au plus vite, en vibe-code (ce premier jet où je prends ce que la machine propose et j’avance sans trop me retourner), puis la réparer intelligemment en lui passant nos process siliceum. Le vibe-code pose la matière brute ; les checklists qualité la durcissent, défaut après défaut. Cette histoire de config, c’est un échantillon parfait de la seconde moitié.
Une virgule de trop
Voici ce qui s’était passé, et c’est presque trop bête pour être vrai.
Granit lit sa config depuis des variables d’environnement. L’une d’elles, GRANIT__CORS__ALLOWED_ORIGINS, liste les domaines autorisés à taper sur l’API. Une liste, donc, écrite avec des virgules : https://granit-golem.com,https://app.granit-golem.com.
Sauf que le code qui lit l’environnement ne savait pas que cette virgule séparait des éléments. Personne ne le lui avait dit. Pour lui, c’était une seule chaîne bizarre qu’il fallait transformer en liste, et il n’y arrivait pas. Donc il levait une erreur.
Une erreur parfaitement saine, au fond. Le genre qui devrait arrêter net le démarrage et crier « ta config est illisible, je ne bouge pas ». Sauf qu’au lieu de remonter, cette erreur tombait sur ceci :
builder.build()?.try_deserialize().unwrap_or_default()
Ce petit .unwrap_or_default() à la fin, c’est toute l’histoire. Il dit : « si la lecture échoue, prends les valeurs par défaut et continue ». Une seule variable illisible, et ce n’est pas juste le champ CORS qui retombe sur son défaut. C’est la config entière. Tout le bloc, d’un coup.
La cause
- une liste à virgulesles origines CORS, sans séparateur déclaré
- parsing en échecimpossible de découper la chaîne en liste
La bascule
- unwrap_or_default()l'erreur est avalée, sans un mot
- tout repart à zérola config entière retombe sur ses défauts
La conséquence
- pool de 40 à 3la valeur du code, pas celle de la prod
- pages qui s'enlisentles requêtes font la queue, le fetch expire
Parmi tous ces réglages remis à zéro, un seul allait faire mal : la taille du pool de connexions à la base. En production, je l’avais fixée à quarante. Le défaut écrit dans le code, lui, vaut trois (une valeur prévue pour du développement local, où trois connexions suffisent largement).
Quarante à trois. Sans un log, sans un avertissement. L’application a démarré « avec succès », sur une config par défaut taillée pour un poste de dev, et a servi la prod comme ça pendant deux mois sans que rien ne cloche.
Trois connexions, et tout fait la queue
Trois connexions, ça paraît peu, mais est-ce vraiment dramatique ? Tant que le trafic est calme, non. Une connexion se libère, sert la requête suivante, se libère encore. Ça tient.
Le problème, c’est la rafale. Une page de tableau de bord ouvre dix requêtes d’un coup pour se remplir. Avec quarante connexions, elles partent ensemble. Avec trois, sept attendent leur tour. Et quand le tour ne vient pas assez vite, le navigateur abandonne : le fetch expire, le squelette reste gris.
Les pages les plus touchées étaient les plus gourmandes (le dashboard, la liste des checks), celles qui demandent le plus de données en même temps. Exactement celles qu’un utilisateur regarde en premier.
Et voilà pourquoi il a fallu deux mois. Pendant tout ce temps, l’usage réel restait sous le seuil : trois connexions suffisaient pour ce que l’app demandait vraiment. Le défaut n’était pas faux, il était trop juste. Puis les besoins ont grossi, et les trois connexions n’ont plus suivi. Le temps que je tombe dessus, le déploiement coupable était à des semaines derrière moi, et plus personne ne faisait le lien.
Le silence, ce complice
La virgule, je m’en remets vite. Ce qui me reste en travers, c’est qu’aucun de mes garde-fous n’a bronché.
Cinq filets, tous au vert5 gates
- Le code était valide. L'erreur ne naissait qu'au démarrage, en lisant l'environnement
- Pire : un test affirmait qu'un pool de 3 était la valeur attendueil gravait le défaut comme une vérité
- Une base légère, presque sans concurrence. Trois connexions suffisaient amplement
- Trois connexions répondent à un ping. /health était contentil teste que ça répond, pas que ça tienne la charge
- Les métriques du pool existaient, mais aucune alerte ne veillait sur sa saturationun pool famélique ressemble à un pool tranquille
Chaque garde-fou a fait son travail et a répondu « tout va bien ». Aucun ne regardait au bon endroit.
Le test unitaire, lui, mérite une mention spéciale. J’avais un test qui chargeait la config par défaut et vérifiait que le pool valait trois. Il passait au vert, fidèlement. Sauf qu’il ne testait pas un comportement correct : il gravait le bug dans le marbre. Il affirmait qu’un pool de trois était la valeur normale, alors que c’était précisément le symptôme.
Quant au /health, il faisait son travail honnêtement, et c’est bien le souci. Il vérifie que la base répond. Avec trois connexions, elle répond. Ce qu’il ne regarde pas, c’est si elle tiendrait la charge un mardi midi.
Une panne que personne ne mesure finit par passer pour le réglage normal de la maison.
Refuser de démarrer
La vraie correction tient en une phrase. Une config illisible ne doit pas se rattraper. Elle doit faire tomber le programme, debout, au démarrage.
En pratique, le geste est minuscule. J’ai enlevé le .unwrap_or_default() et laissé l’erreur remonter :
let config: ServerConfig = builder
.build()?
.try_deserialize()
.context("config illisible : je refuse de démarrer sur les valeurs par défaut")?;
Tout est dans le ? final. Là où .unwrap_or_default() avalait l’erreur et continuait en douce, lui la laisse remonter et coupe le boot net. Trois caractères, et le programme s’arrête au lieu de continuer à mentir.
Restait à désamorcer la cause initiale. J’ai donc appris au lecteur d’environnement que la virgule des champs CORS sépare des éléments de liste (ce qu’il ignorait, et qui faisait tout dérailler). Puis j’ai posé un test de non-régression qui rejoue le scénario exact : une liste d’origines à virgules ne doit plus jamais réinitialiser le reste de la config.
Et puisque rien ne m’avait prévenu, j’ai posé l’alerte qui manquait : une règle qui sonne quand le pool de connexions sature. Ironie de la chose, ce sont ces mêmes métriques de pool, une fois le nez dessus, qui m’ont permis de débusquer le bug. Elles existaient déjà. Personne ne les écoutait. Maintenant, elles crient avant qu’un humain ait à s’en apercevoir.
Mieux vaut un service qui refuse de se lever qu’un service qui se lève à moitié.
Le pool soigné, la page toujours bavarde
Remonter le pool à quarante a éteint l’incendie. Mais en y regardant de près, la panne tenait à deux bugs empilés, pas à un seul. D’un côté, la config tombée à trois connexions. De l’autre, une page qui multiplie les appels pour s’afficher.
Chacun, seul, passait inaperçu. Quarante connexions encaissaient sans broncher cette rafale ; trois connexions auraient suffi à une page sobre. Il a fallu les deux ensemble pour tout faire tomber.
Deux bugs anodins qui s’attendaient.
Remonter le pool soigne le premier. Le second, je l’avais déjà en tête. Cette page trop bavarde, je savais qu’il faudrait y revenir ; elle dormait dans ma pile, en attente que les écrans se stabilisent côté features avant que je touche à leur plomberie.
Car l’approche en place est la plus simple : une requête par bout de données affiché, ajoutée au fil de l’eau. Commode pour avancer tant que les maquettes remuent. Mais mises bout à bout, ces requêtes finissent par étrangler chaque écran sous une nuée d’appels. Un pool plus large absorbe la nuée, il ne la fait pas disparaître.
La vraie réponse, c’est un backend for frontend (un étage serveur taillé sur mesure pour la page, qui rassemble en une seule réponse ce que le front allait chercher en dix). C’est un chantier ouvert chez moi en ce moment, assez gros pour mériter son propre récit. Mais ça, c’est une autre histoire, que je garde pour un carnet à lui seul.
L’angle mort que je croyais surveiller
Cette parade, je la connais par cœur, et c’est bien ce qui me hérisse. Faire tomber un programme sur une config douteuse plutôt que le laisser tourner sur des valeurs fantômes, c’est un de mes premiers réflexes en revue de code. Je le vérifie presque machinalement. Alors comment a-t-il filé entre mes doigts ?
Le coupable, c’est le vibe-code lui-même. J’avance vite, je prends ce que la machine propose, et je ne repasse pas toujours derrière avec la rigueur que je m’imposerais à la main. Le unwrap_or_default() tombait sous le sens, il compilait, ça démarrait. J’ai validé sans m’arrêter sur ce que ce petit suffixe escamotait. Ma vigilance habituelle est restée au vestiaire, le temps d’un commit.
Le test souffre du même mal, en plus sournois. Je l’ai déjà pointé : il gravait un pool de trois comme valeur attendue. Mais le vrai défaut est ailleurs. Sa valeur de référence était la valeur par défaut elle-même.
Vous voyez le piège ? Un test qui vérifie qu’on retombe sur le défaut ne pourra jamais, par construction, repérer un retour silencieux au défaut. Pour le coincer, il fallait une valeur étrangère au défaut, choisie exprès (quarante, surtout pas trois), et exiger qu’elle survive au démarrage. C’est exactement ce que fait le test de non-régression aujourd’hui.
Ces deux réflexes deviennent alors deux lignes de checklist. C’est tout le principe de la méthode siliceum : un défaut que la phase de réparation débusque ne se corrige pas une fois, il devient une règle écrite. La prochaine passe de vibe-code ne pourra plus refaire la même bêtise sans qu’une case la rattrape. Deux lignes de plus, donc : une config illisible doit faire tomber le boot, et un test de config ne se mesure jamais à sa propre valeur par défaut.
Ce que j’en retiens
Ce qui a fini par attraper la panne, ce n’est aucune de mes sondes. C’est moi, en recette, devant un squelette gris. Toute ma surveillance automatique était au vert.
Pour quelqu’un qui construit un outil de surveillance, ça pique un peu.
Et le vrai sujet est là, pas dans la virgule. Mes sondes vérifient que Granit répond, pas qu’il fait son travail. Un ping satisfait ne rejoue aucun parcours client. Ce scénario que j’ai déroulé à la main, elles auraient dû le dérouler sans moi.
Alors je le leur ai appris. J’ai semé des tests synthétiques sur toute la prod : un faux utilisateur, un faux compte, qui rejouent en boucle les vrais parcours et sonnent là où un ping reste muet.
Ce que cette panne a gravé dans la checklist4 gates
- Une config illisible fait tomber le bootau lieu de retomber en silence sur les défauts
- Un test de config se compare à une valeur choisie, jamais au défautsinon il grave le bug au lieu de l'attraper
- Une alerte sonne quand le pool de connexions saturele silence ne doit plus passer pour du calme
- Des tests synthétiques rejouent les vrais parcours en continuun faux compte qui vérifie que ça marche, pas seulement que ça répond
Une ligne par leçon. La prochaine passe de vibe-code se cognera dans ces cases avant de refaire la même bêtise.
Bâtir ce faux client pour qu’il soit crédible, lui apprendre à se méfier là où je me méfierais, le brancher sur Basalt Beholder et la grille qualité de siliceum : il y a là de quoi remplir un carnet entier. Celui-là, je vous le dois.
Avant, je tombais sur les squelettes gris par hasard, un soir de recette. Maintenant, quelqu’un veille à ma place, et ne dort jamais.