Par Juste Thales Gnimavo (CEO, ZeroSuite) et Claude Opus 5.5 (Claude Code)
Déblo Duo est une application d'interprétation en temps réel : chacun parle sa langue et entend l'autre dans la sienne. Pourquoi Déblo Duo existe présente le produit. Duo tourne sur le même backend que Déblo, notre application d'apprentissage, et partage sa table des sessions vocales, mais le défaut de cet article se trouvait dans le chemin d'appel vocal propre à Déblo, celui qui facture un appel à la minute. Cet article raconte un test rouge que tout le monde aurait qualifié d'instable, le défaut de facturation qu'il désignait, et un second défaut, plus grave, trouvé en rejouant le code. Tout s'est passé entre le 4 et le 6 novembre 2026.
4 novembre : un test rouge hors du lot
Le 4 novembre, une vérification complète du backend a rendu deux échecs sans rapport avec le lot contrôlé. L'un d'eux était un test de facturation voix. Il termine un appel simulé d'environ deux minutes et vérifie que la durée facturée reste sous une borne. Il affichait :
assert 150 <= 130Environ 150 secondes mesurées pour un appel d'environ 120. Trente secondes de trop.
La vérification ne s'est pas arrêtée là-dessus. Les deux échecs étaient hors du lot, ils sont donc entrés dans la file comme une phase à part.
6 novembre : pas instable, un vrai défaut qui se cache
Le 6 novembre, la phase a tourné comme une session d'une chaîne de quatre. Claude a rejoué le test contre le code au lieu de le relancer jusqu'à ce qu'il passe au vert.
La fonction qui termine un appel, finalize_session, faisait trois choses dans cet ordre : supprimer la salle de l'appel sur LiveKit Cloud, attendre la réponse, puis calculer la durée facturée comme « maintenant moins le début ». Le test ne neutralisait pas la suppression. Il appelait LiveKit Cloud pour de vrai, sur une salle qui n'existait pas, avec les identifiants de l'environnement local. Le 4 novembre, cet appel a pris environ 30 secondes. La durée facturée les incluait.
Puis le détail qui rend ce genre de défaut dangereux. Relancé le 6 novembre sans aucun changement, le test est passé en 1,67 seconde. LiveKit répondait vite ce jour-là. Un test qui échoue un jour et passe le lendemain sans changement de code, c'est la définition même d'un test instable, et le réflexe habituel consiste à le relancer, voir du vert et passer à autre chose. Ici, le passage au vert ne prouvait rien. Il prouvait seulement que le défaut dépend de la latence de quelqu'un d'autre.
En production, cette latence, c'est l'utilisateur qui la paie. Quand LiveKit répond lentement, l'utilisateur paie notre attente.
Un correctif que l'agent n'a pas appliqué
La session a corrigé le test : la suppression est désormais remplacée par un faux qui enregistre la salle, et le test ne dépend plus du réseau. Elle n'a pas corrigé la facturation. Le log dit pourquoi :
« Niveau 1, à trancher par le CEO : la durée facturée inclut la latence de delete_room_force. »
Nos règles de projet classent au niveau 1 tout changement du calcul d'un débit : l'agent s'arrête et le CEO décide. Aucun utilisateur n'avait signalé le défaut, ce qui rendait tentant de glisser le correctif de deux lignes dans la session. La règle existe précisément pour cette tentation.
La même entrée portait la mauvaise nouvelle. L'exposition réelle était inconnue. La latence de suppression en production était journalisée au niveau info, et le logger racine ne garde que les avertissements et au-dessus : toutes ces lignes avaient été filtrées. Nous savions que le défaut pouvait facturer des secondes que personne n'avait parlées. Nous ne pouvions pas dire combien il en avait facturé.
Juste a lu le digest de la chaîne et répondu : « oui applique le correctif facturation, et relance la chain ».
Le correctif est celui que Claude avait proposé : relever l'heure de fin avant de supprimer la salle, calculer la durée facturée à partir d'elle, et stocker cette même heure comme fin de session, pour que l'heure de fin et la durée facturée concordent. Un audit ciblé, obligatoire sur tout ce qui touche aux paiements, a rendu GO-WITH-FIXES. Il a confirmé que la durée ne peut ni devenir négative ni rendre des crédits à tort. Il a aussi signalé deux faiblesses plus anciennes, à traiter dans une session dédiée : la route de fin d'appel chargeait la session sans verrou, et les crédits affichés à l'utilisateur étaient recalculés depuis la durée au lieu d'être lus dans ce qui avait réellement été débité. Sur le premier point, l'audit écrivait qu'un double débit restait bloqué par la clé d'idempotence du débit final.
Cette dernière phrase était fausse.
Le défaut plus grave : une minute, deux clés
Juste a validé la session dédiée le jour même : « oui corrige en session dédiée ». Elle a tourné plus loin dans la chaîne et a commencé, comme chaque session, par rejouer les constats contre le code.
Deux morceaux de code peuvent terminer et facturer un appel. Le premier est la route que l'application appelle quand l'utilisateur raccroche. Le second est une boucle de fond qui facture les appels en cours minute par minute, pour qu'un appel qui ne raccroche jamais proprement soit quand même facturé. Les deux lisaient la ligne de session. Aucun ne la verrouillait.
La course, telle que la session l'a écrite :
- La route lit la session : N minutes déjà facturées.
- La boucle facture la minute N+1, sous une clé de la forme
…:tick:{n}. - La route calcule son delta final à partir de son N périmé et facture à nouveau la même minute, sous la clé
…:end.
Chaque débit est idempotent sur sa propre clé. Les deux clés sont différentes. L'idempotence sur laquelle nous comptions ne protégeait donc pas du tout ce cas. L'audit précédent avait lu « le débit final est idempotent » et en avait conclu « pas de double débit ». Rejouer la séquence pas à pas a montré que les deux pouvaient être vrais en même temps qu'un double débit.
Le correctif, déployé le 6 novembre à 12:30 UTC :
- Une fonction verrouille la ligne de session par
SELECT … FOR UPDATEet rafraîchit la copie de l'appelant. La fonction de fin prend le verrou après ses appels réseau (suppression de la salle, lectures chez le fournisseur, taux de change) et avant tout débit, puis relit le statut sous verrou. Un second finaliseur attend le commit du premier, puis rend le résultat stocké sans débiter à nouveau. - La boucle de fond verrouille la même ligne en
SKIP LOCKEDavant de lire ce qui a été facturé. Une ligne tenue par un finaliseur est sautée pour ce cycle. - L'ordre des verrous est le même partout : la session, puis l'utilisateur. Aucun chemin ne prend l'utilisateur d'abord, donc aucun interblocage introduit.
- Les crédits affichés pour un appel sont désormais la somme des débits de cet appel dans le grand livre, et non un recalcul.
- Le compteur invités est marqué une seule fois par session avant d'être incrémenté : une fin rejouée compte une fois.
Dix-neuf tests ciblés sont passés, dont des tests nouveaux où un instantané périmé ne doit pas débiter deux fois et où les crédits affichés doivent égaler le grand livre même quand un tick a été tarifé hors barème. L'audit a rendu GO-WITH-FIXES, sans défaut sur le verrou lui-même, et ses correctifs ont été appliqués dans la même session.
La preuve sur un vrai appel
Des tests ne sont pas une preuve. À 12:47 UTC, Juste a passé un vrai appel vocal authentifié dans Déblo, depuis l'application publiée. Le correctif est côté serveur, la version de l'application ne changeait donc rien. Claude a ensuite lu les lignes de production, en lecture seule :
start 12:42:28 end 12:45:04 clock 156 s duration_seconds 155
transactions 2 (tick:1, tick:3) no :end key credits shown = ledgerCe que cela prouve :
- La durée facturée est de 155 secondes pour 156 secondes d'horloge. L'attente de LiveKit n'est plus facturée.
- Deux transactions, deux clés distinctes, aucun débit final. Pas de double débit.
- Les crédits affichés égalent la somme du grand livre.
Une lecture demande de l'attention. Trois minutes ont été facturées pour 2 minutes 35 secondes. C'est la règle voulue dans ce mode de tarification : une minute entamée compte, comme chez un opérateur téléphonique. Ce n'est pas le défaut.
Ce qui reste ouvert
Le passé. Nous avons prouvé le comportement après le correctif, le 6 novembre. Nous ne pouvons pas dire combien a été surfacturé avant. La latence de suppression de salle en production le 4 novembre n'a jamais été mesurée, et les lignes qui l'auraient mesurée ont été filtrées par leur niveau de journal. L'ampleur de l'ancienne exposition reste donc inconnue, et nous l'écrivons ainsi plutôt que d'avancer un chiffre rassurant.
À quoi sert un test rouge
Un test qui échoue une fois et passe à la relance n'est pas du bruit tant que personne n'a expliqué l'échec. Celui-ci mesurait exactement ce qu'un utilisateur aurait payé un jour de lenteur. La relance l'a fait passer au vert et aurait enterré le défaut pour de bon.
La seconde leçon concerne l'audit. Il avait raison sur tout ce qu'il a vérifié, et tort sur une phrase qu'il a supposée. « Le débit est idempotent » était vrai pour chaque clé. Le défaut vivait entre deux clés. Ce qui l'a trouvé n'est pas un meilleur audit, mais une session qui a rejoué la séquence dans l'ordre avant de toucher au code.