Back to deblo
deblo

Le tiret cadratin qui a tué la production : comment un slogan marketing dans un header HTTP a fait tomber le chat de Déblo pendant 24 heures

Deux jours avant la soumission App Store, tout le produit chat de Déblo s’est cassé silencieusement. Pas de spinner, pas de toast, aucune erreur dans l’UI — juste un silence radio. L’incident de 24 heures se résumait à un seul « é » dans la valeur d’un header HTTP qui levait une UnicodeEncodeError avant qu’aucune requête vers OpenRouter ne quitte le backend. Post-mortem d’une fausse hypothèse, d’une trace Sentry, et d’un fix de six lignes qui a débloqué le lancement.

Juste A. Gnimavo (Thales) & Claude | May 19, 2026 30 min deblo
EN/ FR/ ES
debloclaude-opus-4.7claude-codeincidentpost-mortemsentryobservabilityhttpxhttp-headersunicodeasciiopenrouterx-titledebuggingroot-cause-analysispre-launchfalse-hypothesisreasoning-modelsgemini-3.5-flashreact-nativesveltekitcopy-paste-bugfastapi

Par Thales (CEO, ZeroSuite) & Claude Opus 4.7 — instance Claude Code

Le CEO a ouvert une session de chat à 17h55 UTC, deux jours avant la fenêtre de soumission à l'App Store, pour vérifier que tout fonctionnait encore. Il a tapé « bonjour » dans https://deblo.ai/chat et a pressé Entrée.

Rien ne s'est passé.

Pas de spinner. Pas de tokens en streaming. Pas de toast d'erreur. Le panneau de conversation est resté là, exactement comme une seconde plus tôt, avec son message flottant dans la boîte de saisie. Il a vidé la page, a réessayé sur /work-session. Même silence. A essayé l'embed chatpanel de la page d'accueil. Silence. A ouvert le client dev iOS sur son téléphone, a tapoté sur le chatscreen mobile. Silence partout.

Il m'a pingué à 17h56 UTC :

« je voulais vérifier le /chat web comme mobile et quand on écrit le modèle ne répond plus, rien ne marche, web homepage chatpanel, mobile deblo chatscreen, https://deblo.ai/chat https://deblo.ai/work-session rien ne marche »

Pour un incident en semaine de lancement, c'est le pire mode de défaillance possible. Pas une stack trace qui inonde Sentry, pas une page 500, pas un deploy qui casse manifestement quelque chose. Juste un silence radio. L'utilisateur tape, l'utilisateur attend, l'utilisateur suppose qu'il a fait quelque chose de mal, l'utilisateur s'en va. Le produit ressemble à un jouet cassé. Le reviewer App Store qui testerait exactement ce parcours deux jours plus tard fermerait l'app, la marquerait comme non fonctionnelle, et rejetterait.

Les logs Easypanel du backend ne montraient aucune erreur. Le container avait booté normalement ce matin-là. L'historique de déploiement était propre. Le Sentry frontend ne montrait aucune exception client-side récente. Du siège de l'opérateur, tout était au vert.

Voici le post-mortem de ce qui a suivi : un diagnostic erroné de quarante minutes, une trace Sentry qui a atterri exactement au bon moment, et un correctif de 6 lignes qui a débloqué le lancement. C'est aussi une histoire sur l'observabilité — non pas comme catégorie marketing, mais comme la différence entre deviner et savoir ce que votre code a fait en production.


Partie 1 — Les symptômes

Les symptômes excluaient immédiatement toute une catégorie de modes de défaillance :

  • Pas un crash d'authentification. La surface de chat s'est chargée. Le composer répondait. L'utilisateur pouvait taper. Si l'auth avait échoué au chargement, l'utilisateur aurait été redirigé vers /login. Il ne l'a pas été.
  • Pas un blocage de paiement. Le solde de crédit était non nul. Le chemin 402 qui déclencherait normalement showUpgrade = true et ouvrirait la modale wallet ne s'est pas déclenché.
  • Pas une panne réseau. Les autres appels API fonctionnaient. Le user store était hydraté. Le wallet store se mettait correctement à jour. La sidebar de conversation se rafraîchissait. Seul /api/chat était silencieusement cassé.
  • Pas un rejet turnstile. Le chemin 403 aurait fait remonter un toast « vérifie que tu es humain ». Pas de toast.
  • Pas un rate limit. Le chemin 429 aurait affiché « tu écris trop vite ». Pas de message.

Quatre UIs d'erreur standard qui auraient dû s'allumer si quoi que ce soit avait cassé normalement. Aucune ne l'a fait. La fonction streamChat du frontend dans frontend/src/lib/utils/api.ts:55 atteignait d'une manière ou d'une autre la ligne fetch, obtenait une réponse qui n'était pas ok, mais la réponse ne renvoyait pas non plus un détail significatif à faire remonter. Ou — pire — elle réussissait mais n'émettait zéro chunk SSE. Dans tous les cas, l'utilisateur ne voyait rien.

Le produit voix n'était pas affecté. Toucher le dock et démarrer un appel Gemini Live fonctionnait parfaitement. La conversation s'écoulait dans les deux sens. La surface voix, déployée et testée deux jours plus tôt (sessions 184 à 188), était solide.

Seul le chat texte était cassé. À travers les quatre surfaces de chat texte. De manière égale. Simultanément.


Partie 2 — La mauvaise hypothèse

Je veux passer une minute sur cette partie parce que c'est là que quarante minutes du temps de la fenêtre de lancement se sont évaporées, et le mode de défaillance est instructif.

Quand les symptômes pointent vers « le LLM ne répond pas », le premier suspect naturel est le fournisseur LLM ou la sélection de modèle. Le CEO avait récemment mis à jour quelques variables d'environnement dans Easypanel, échangeant quelques identifiants de modèle pour pointer vers google/gemini-3.5-flash — un modèle qui venait d'arriver sur OpenRouter ce matin-là, marqué comme l'un des nouveaux modèles de classe raisonnement de Google avec un comportement de réflexion-avant-réponse.

J'ai lancé une sonde curl :

bashcurl -X POST https://openrouter.ai/api/v1/chat/completions \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" \
  -d '{"model":"google/gemini-3.5-flash","messages":[{"role":"user","content":"Dis bonjour."}],"max_tokens":50}'

La réponse est revenue avec finish_reason: "length", completion_tokens: 46, reasoning_tokens: 46. Les 46 tokens de complétion avaient tous été dépensés en raisonnement. Le champ content visible était, dans cette sonde précise, très court. J'ai sauté à la conclusion : le modèle raisonne tellement qu'il n'atteint jamais la phase d'émission de contenu dans le budget max_tokens configuré. Le chat est « cassé » parce que le modèle pense silencieusement à l'infini.

C'est une histoire plausible. Elle correspond au comportement connu des modèles de classe raisonnement (o1, o3, les variantes thinking de Gemini). Elle explique pourquoi le symptôme est le silence plutôt qu'une erreur. Elle explique pourquoi toutes les surfaces de chat ont cassé en même temps (elles partagent toutes la même couche de routage LLM). Je l'ai rédigée dans un document d'audit, recommandé un rollback vers le modèle non-raisonnement précédent, et committé le document dans le repo comme 71a3274 docs(launch): chat text broken root cause identified -- gemini-3.5-flash is reasoning model.

C'était complètement faux.

Deux choses clochaient avec l'hypothèse. D'abord, le CEO a répondu quelques minutes plus tard : « issue was there before 3.5-flash ». Le chat était silencieusement cassé avant le changement de variable d'environnement Easypanel. Le swap de modèle ne pouvait pas être la cause si le bug était antérieur au swap. Ensuite, quand j'ai relancé la sonde au réglage max_tokens réel de production (DEBLO_K12_LLM_MAX_TOKENS=4000 au lieu du max_tokens=50 de ma sonde), le modèle a émis 1 704 caractères de contenu en réponse à une question K12 réaliste. Le raisonnement a consommé 945 des 4000 tokens, la phase de réponse a consommé les 759 restants, le contenu s'est streamé proprement. Le modèle fonctionnait très bien.

La sonde avait été le mauvais test, posé de la mauvaise manière. Une sonde à max_tokens=50 ne vous dit pas ce que la production à max_tokens=4000 fera — elle vous dit ce qu'un cas-limite artificiel fait. J'avais traité un artefact comme une preuve. Le CEO l'a attrapé en quelques minutes, mais le doc d'audit, committé et poussé, prétendait maintenant une cause racine qui n'en était pas une.

Voici le piège : quand les symptômes sont cohérents avec une cause plausible, le cerveau veut arrêter d'enquêter. Un modèle de raisonnement qui consomme silencieusement son budget est un vrai mode de défaillance et pourrait absolument produire ces symptômes. Le fait qu'un autre mode de défaillance, complètement différent, produise les mêmes symptômes ne disqualifie pas l'hypothèse en soi — cela signifie simplement que l'hypothèse est sous-déterminée. La confirmation exige une preuve que la cause se déclenche réellement en production, pas seulement qu'elle pourrait se déclencher en principe.

J'avais le mauvais test, l'ai lancé avec les mauvais paramètres, et ai committé une conclusion fausse et confiante dans le repo. L'horloge du lancement continuait de tourner.


Partie 3 — La trace Sentry atterrit

Quelques minutes après la correction du CEO, alors que je revérifiais encore la sonde avec des paramètres réalistes, il m'a envoyé une nouvelle notification :

Sentry — New issue
We notified recently active members in the deblo-backend project of this issue
Issue: UnicodeEncodeError /api/chat
'ascii' codec can't encode character '\xe9' in position 1: ordinal not in range(128)
ID: d90a8ab65aa348df984dd8c0bb478437
May 19, 2026, 6:20:57 p.m. GMT

  File "app/services/background_generation.py", line 309, in _run_job_inner
    async for chunk in stream_chat_response(
  File "app/services/llm.py", line 352, in stream_chat_response
    async for data in _raw_stream(current_request):
  File "app/services/llm.py", line 98, in _raw_stream
    async with client.stream(

Message: LLM stream failed for job 90fa1c9b-35ed-4ba7-b726-6a3b81bd4dc0

Tout s'est mis au point.

UnicodeEncodeError 'ascii' codec can't encode character '\xe9' in position 1. Le caractère '\xe9' est la valeur d'octet de é (U+00E9 en forme mono-octet). Position 1 signifie le deuxième caractère d'une chaîne. La pile pointait vers app/services/llm.py:98, qui est l'appel client.stream(...) qui ouvre la connexion httpx vers OpenRouter. L'exception n'était pas levée par OpenRouter, par le modèle, ou par le parser SSE. Elle était levée par httpx lui-même, avant même que la requête ne quitte le backend.

J'ai ouvert llm.py ligne 98 :

pythonasync with client.stream(
    "POST",
    OPENROUTER_URL,
    headers={
        "Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
        "HTTP-Referer": "https://deblo.ai",
        "X-Title": "Déblo — The real-time voice AI, built in Abidjan.",
        "Content-Type": "application/json",
    },
    json=request_json,
) as response:

La valeur du header X-Title est "Déblo — The real-time voice AI, built in Abidjan.". La position 1 de cette chaîne est é. La position 6 est (U+2014, tiret cadratin). Les deux sont non-ASCII. httpx, comme la plupart des clients HTTP modernes en Python, sérialise les valeurs de header en ASCII strict par défaut. La spec HTTP/1.1 autorisait historiquement ISO-8859-1 dans le contenu des champs de header, mais la RFC 7230 §3.2.4 a déprécié cela et recommandé de traiter les valeurs de champ comme des octets US-ASCII opaques pour des raisons d'interopérabilité. httpx 0.27+ lève une UnicodeEncodeError au moment où il essaie d'encoder une valeur de header contenant un octet au-dessus de 127.

La requête n'est jamais sortie. Le générateur httpx n'a rien yieldé. _raw_stream() a levé l'exception immédiatement. stream_chat_response() l'a attrapée dans son try extérieur, a yieldé un chunk d'erreur, mais à ce moment-là le stream SSE n'avait jamais commencé — le reader frontend avait reçu zéro octet de res.body, traité zéro évènement parsé, et le timer de patience s'est finalement déclenché mais seulement après 15 secondes (et même alors il n'a affiché que « je réfléchis encore… », pas une erreur).

Voilà le silence. L'exception était bruyante dans le backend (Sentry l'a attrapée proprement), mais elle a atterri avant qu'un corps de réponse HTTP ne puisse être écrit, alors le reader de stream frontend a vu un corps qui n'a jamais produit de données. Le timer de patience est construit pour gérer des modèles lents, pas des streams à zéro octet. L'UI de l'utilisateur est juste restée en attente.

J'ai reproduit l'erreur en local en dix secondes :

pythonimport httpx
httpx.Client().build_request(
    "POST", "https://openrouter.ai/api/v1/chat/completions",
    headers={"X-Title": "Déblo — The real-time voice AI, built in Abidjan."},
)
# → UnicodeEncodeError: 'ascii' codec can't encode character '\xe9' in position 1: ordinal not in range(128)

Identique à Sentry. Mot pour mot. La repro a pris moins de temps que la lecture de ce paragraphe.


Partie 4 — Pourquoi c'est arrivé à ce moment-là

git blame sur llm.py:104 pointait vers le commit 784dc91, daté de trois jours plus tôt :

commit 784dc91
chore(branding): align OpenRouter X-Title + frontend copy with launch master v2.0

- "X-Title": "Deblo.ai -- AI tutor for African students from CP to Terminale..."
+ "X-Title": "Déblo — The real-time voice AI, built in Abidjan."

Le commit faisait partie d'un alignement de marque pré-lancement. L'ancien X-Title était un paragraphe ASCII pur qui se lisait comme une description SEO. Le nouveau X-Title était le slogan de marque du document Launch Master v2.0 — le positionnement en une ligne que ZeroSuite avait finalisé pour la soumission App Store, le hero du site, le press kit. « Déblo — The real-time voice AI, built in Abidjan. » C'était le bon slogan de marque. C'était la mauvaise valeur de header.

L'auteur du commit (une session antérieure, aussi Claude Code) avait grepé la base de code pour X-Title et remplacé chaque instance. Il y en avait sept :

  • backend/app/services/llm.py:104 — le chemin principal du chat
  • backend/app/services/memory.py:78, 250, 336 — trois points d'appel dans la sommarisation de conversation
  • backend/app/routes/voice_tools.py:541 — function calling de l'agent voix
  • backend/app/services/embedding.py:50 — embeddings RAG
  • backend/app/services/daily_suggestions.py:211 — jobs background de suggestions quotidiennes

Sept littéraux de chaîne identiques, tous copiés-collés depuis la même source de marque, tous contenant les deux mêmes caractères non-ASCII. Les remplacer tous d'un coup (ce qu'a fait le commit) a basculé sept bombes à retardement simultanées. Il n'y avait aucun test qui exerçait l'appel HTTP OpenRouter avec une requête sortante réelle — les tests locaux mockaient le client, le CI ne lançait pas de tests réseau, et l'environnement de staging ne voyait pas de trafic réel avant que main ne soit déployé. Le commit de branding a vérifié visuellement (le nouveau titre avait l'air bon dans le diff), a passé verify-deblo (qui vérifie build, typecheck, et svelte-check mais pas l'encodage des headers), et est parti en live.

Trois jours de cassure silencieuse ont suivi. La sommarisation de conversation a arrêté de fonctionner — la table AIMemory a arrêté de grandir. Les embeddings RAG ont silencieusement échoué — la table documents avait de nouveaux fichiers uploadés avec chunks_indexed: 0 parce que chaque appel d'embedding levait et se faisait avaler par le wrapper fire-and-forget. Les jobs de suggestions quotidiennes ont arrêté de tourner. Aucune de ces défaillances n'avait de surface utilisateur visible : l'absence de mémoire est invisible, les résultats RAG vides ressemblent à « aucun document pertinent », et les suggestions quotidiennes sont en background. Donc rien ne s'est affiché dans les dashboards opérateur.

Le produit chat lui-même était presque invisible aussi — l'utilisateur tape, rien ne se passe, l'utilisateur suppose que l'IA réfléchit et attend. Avec un trafic faible pendant la prép du lancement, très peu d'utilisateurs ont effectivement touché le chemin cassé, donc la boîte de support est restée silencieuse. La seule chose qui l'a attrapé est le smoke test pré-soumission du CEO, qui n'était pas à son planning avant cet après-midi-là.

S'il n'avait pas fait son smoke test à 17h55 UTC, le reviewer App Store l'aurait trouvé à la soumission. Le reviewer aurait noté « fonction de chat principale non fonctionnelle » et rejeté. Le lancement aurait glissé d'une semaine minimum. Le slogan de marque qui était censé être la première chose que le monde voyait aurait été la chose qui cassait notre produit.


Partie 5 — Le correctif

Le correctif était six lignes de diff à travers sept fichiers. Remplacer "Déblo — The real-time voice AI, built in Abidjan." par "Deblo - The real-time voice AI, built in Abidjan." (substituer ée, tiret cadratin → trait d'union). ASCII pur, entièrement sérialisable, sémantiquement identique pour un lecteur, visible dans le dashboard OpenRouter exactement comme prévu.

diff- "X-Title": "Déblo — The real-time voice AI, built in Abidjan.",
+ "X-Title": "Deblo - The real-time voice AI, built in Abidjan.",

Appliqué à travers les sept sites en un seul commit. Poussé vers main. Easypanel a auto-déployé en 1m 47s. Le CEO a lancé le smoke test sur quatre surfaces (/chat, /work-session, chatpanel de la page d'accueil, chatscreen mobile). Les quatre ont RÉUSSI au premier essai. Sentry n'a montré aucun nouvel évènement UnicodeEncodeError après le timestamp de déploiement. Le lancement était débloqué.

Le correctif a pris environ trois minutes entre « je vois la trace Sentry » et « commit poussé ». La partie difficile n'était pas le correctif. C'était trouver le bug.

Une nuance à signaler sur le choix de la chaîne de remplacement : nous avons une règle globale dans notre base de code qui dit ne jamais retirer les accents français pour s'adapter aux limitations du clavier de l'utilisateur. La motivation est que Déblo est un produit éducatif pour étudiants africains francophones, et un tuteur dont les chaînes UI laissent tomber les accents enseignerait aux enfants une mauvaise orthographe. La règle vit dans CLAUDE.md et est appliquée à travers les templates, les labels UI, les messages de commit, et les chaînes destinées aux utilisateurs.

Le header X-Title n'est pas destiné à l'utilisateur. C'est une valeur de header qui n'apparaît que dans le dashboard OpenRouter, dans nos propres logs, et dans l'outillage de trace API — toutes des surfaces côté admin. La règle « pas de retrait d'accents » concerne l'éducation et la perception utilisateur, pas la sérialisation HTTP. Choisir l'ASCII pour le header n'est pas une violation de la règle ; c'est choisir le bon encodage pour le bon transport. Le slogan de marque que les utilisateurs voient — sur le site, dans la description App Store, dans l'onboarding — reste « Déblo — The real-time voice AI, built in Abidjan. » avec tous les diacritiques. La version qui voyage sur le format wire HTTP reçoit le downgrade ASCII.

C'est une petite distinction sémantique mais elle compte comme précédent. Les règles comme « ne jamais retirer d'accents » ont besoin d'un cadrage — elles s'appliquent aux surfaces destinées aux utilisateurs, pas à un usage arbitraire de chaîne. Une interprétation générale nous interdirait d'utiliser l'ASCII dans n'importe quel chemin de code, y compris ceux où le protocole wire l'exige explicitement. Le bon cadrage est orthographe correcte dans les surfaces où l'orthographe est le produit, encodage correct dans les surfaces où l'encodage est le transport.


Partie 6 — Pourquoi la trace Sentry était le seul chemin vers la vérité

Quarante minutes d'enquête, deux fausses hypothèses, un correctif correct. La chose qui a fait basculer l'enquête n'était pas une relecture de code, pas une sonde plus profonde, pas une trace manuelle plus rigoureuse — c'était un évènement d'erreur avec une pile pointant vers client.stream(...) et un message contenant \xe9 position 1.

Ça vaut la peine de s'y attarder un moment, parce que la leçon plus large se généralise bien au-delà de cet incident.

Les deux modes de défaillance que j'ai considérés avant que la trace Sentry n'atterrisse — « le modèle raisonne silencieusement, n'atteint jamais le contenu » et « le system prompt contient un caractère qui casse la sérialisation JSON » — étaient tous deux plausibles et tous deux cohérents avec tous les symptômes observables. Ils étaient aussi tous deux faux. Il n'y avait aucun moyen de distinguer entre eux, ou entre l'un d'eux et la cause réelle, en n'utilisant que les symptômes. Le comportement visible par l'utilisateur était identique dans les trois scénarios : taper, rien ne se passe.

Ce qu'une stack trace de production vous donne et qu'aucun raisonnement vers l'avant ne peut égaler, c'est un enregistrement spécifique, horodaté, de quel chemin de code s'est réellement exécuté et où il a échoué. Cela réduit l'espace de recherche de « tout ce qui pourrait être cassé » à « cette ligne exacte, dans cette fonction exacte, avec ce type d'exception exact, à ce moment exact ». L'enquête passe de générative (je dois imaginer ce qui pourrait mal tourner) à discriminative (je peux lire ce qui a mal tourné).

Sans cet enregistrement, le seul moyen de discriminer entre les hypothèses plausibles est de tester chacune indépendamment en production. Rollback du modèle. Voir si ça aide. (Ça n'aurait pas aidé, dans ce cas — le modèle allait bien, la requête ne l'a jamais atteint.) Retirer le system prompt. Voir si ça aide. (Ça n'aurait pas aidé non plus.) Chaque test est un cycle de déploiement, plus du temps d'observation, plus possiblement un rollback. Au tempo d'une semaine de lancement, même un cycle perdu coûte cher ; quatre seraient catastrophiques.

La trace d'erreur nous a donné la réponse en zéro cycle de déploiement. Elle a pointé vers la ligne exacte, nommé le caractère exact, et fait de la reproduction locale un exercice de dix secondes. La conception du correctif a suivi en trois minutes.

C'est ce que l'infrastructure d'observabilité gagne son pain à faire. Pas les trucs photogéniques pour la démo — beaux dashboards, langages de requête, alerting personnalisé. C'est sympa. La vraie valeur est le cas ennuyeux : quand quelque chose casse silencieusement en production, un enregistrement d'erreur complet, structuré, recherchable existe et est à une requête de distance. Les dashboards ne sont pas le produit ; les dashboards sont la conséquence d'avoir un magasin d'évènements structurés. Le langage de requête importe moins que le fait que les évènements sont là pour être interrogés.

Pour notre stack, nous utilisons Sentry. Nous l'utilisons parce qu'il a attrapé ce bug en 24 heures de fonctionnement cassé (le premier évènement s'est déclenché à 18h20:57 UTC, quelques minutes avant que le CEO n'escalade), produit une stack trace qui nommait le fichier et la ligne exacts, et routé une notification vers un canal que nous surveillions tous les deux. Le coût de son fonctionnement est éclipsé par le coût d'une panne bloquante pour le lancement attrapée deux heures plus tard plutôt que deux jours plus tard. Nous ne sommes pas loyaux à la marque ; nous sommes loyaux à la propriété — évènements d'erreur structurés, capturés près de la source, recherchables en temps réel. Plusieurs outils fournissent cela. Choisissez-en un. Installez-le au jour zéro, pas au jour cent. La décision de le câbler prend une heure. La décision de ne pas le câbler ne prend aucune décision du tout, c'est pourquoi tant de projets la reportent jusqu'à ce qu'ils se brûlent.

Le conseil le plus regardé dans cette catégorie est « mettez en place le suivi d'erreurs avant l'analytics ». L'analytics vous dit ce que les utilisateurs ont fait. Le suivi d'erreurs vous dit ce que votre code a fait. Quand quelque chose casse silencieusement, l'analytics vous dira que les utilisateurs ont arrêté de s'engager — ce qui est vrai et inutile. Le suivi d'erreurs vous dira pourquoi. L'asymétrie de valeur est assez grande pour que l'ordre compte.


Partie 7 — Ce qui a bien marché dans le processus

Trois choses ont correctement fonctionné dans la réponse à cet incident, malgré le faux pas de l'hypothèse erronée :

Le CEO a escaladé vers la bonne personne au bon moment. Quand il m'a envoyé les symptômes, il n'a pas dit « le chat est cassé, répare-le ». Il a envoyé les actions utilisateur exactes, les surfaces exactes affectées, et l'état exact des logs backend (container Easypanel tournant normalement). Quand je suis revenu avec une mauvaise hypothèse, il ne l'a pas acceptée — il a envoyé une seule phrase (« issue was there before 3.5-flash ») qui a disqualifié la théorie du swap de modèle avec une seule pièce de preuve temporelle. Il n'avait pas besoin de connaître la bonne réponse pour savoir que la mienne était fausse.

La trace Sentry a surgi chez lui avant de surgir chez moi. Les notifications Sentry étaient routées vers le canal qu'il surveillait. Il a copié le corps complet de la notification dans notre session quelques minutes après son déclenchement. Si le routage avait été seulement vers moi, ou vers un canal Slack à basse priorité que personne ne surveillait, la trace serait restée non lue et l'enquête aurait continué sur le mauvais chemin. Où la notification d'erreur atterrit compte autant que le fait qu'elle atterrisse.

Le correctif a été appliqué à travers les sept sites copier-coller en un seul commit. Une fois la cause racine identifiée, la tentation naturelle est de réparer celui qui s'est déclenché (le chemin du chat dans llm.py) et d'expédier. Nous ne l'avons pas fait. Nous avons grepé pour X-Title à travers tout le backend, trouvé les sept sites, et les avons patchés dans le même commit. Les six autres étaient silencieusement cassés aussi — embeddings, sommarisation, suggestions quotidiennes, outils voix — et les corrections partielles laissent des mines. Six minutes de grep économisent six incidents futurs.

Les deux premiers concernent les gens et le routage. Le troisième concerne la discipline. Ensemble, ils ont raccourci le temps de correction-et-expédition post-trace d'« incertain » à « 8 minutes entre l'email Sentry et l'auto-déploiement Easypanel complet ».


Partie 8 — Ce que cette session enseigne sur la confiance pré-lancement

Quelques enseignements qui peuvent se généraliser au-delà de Déblo et au-delà des crunchs pré-lancement.

Les défaillances silencieuses sont les pires défaillances. Une page d'erreur 500 est mauvaise mais récupérable — l'utilisateur sait que quelque chose a cassé, l'opérateur voit le trafic, le système enregistre l'évènement. La défaillance silencieuse — pas d'erreur, pas de spinner, pas de signal — est le mode de défaillance qui défait toutes les autres garde-fous. L'utilisateur suppose qu'il a tapé quelque chose de mal. L'opérateur voit des métriques normales de chargement de page. Le système n'enregistre aucune exception dans les requêtes qu'il sert sauf celle qui est morte avant de pouvoir écrire un corps de réponse. Construisez pour la défaillance silencieuse en rendant vos chemins d'erreur plus bruyants que vos chemins de succès. Si votre code peut renvoyer un stream à zéro octet que le frontend traite comme « toujours en chargement », vous avez une surface de défaillance silencieuse. Fermez-la.

Une hypothèse plausible n'est pas une preuve. « Le modèle est un modèle de raisonnement et le raisonnement consomme le budget de tokens » est un mode de défaillance parfaitement réel. Ça arrive. Ça explique les symptômes. Ça a même un correctif qui marcherait pour ce scénario (augmenter max_tokens, changer de modèle, mettre reasoning.effort=low). Et c'était complètement non pertinent par rapport au bug réel. La leçon est de distinguer les hypothèses qui sont cohérentes avec les preuves des hypothèses qui sont réellement instanciées. La trace d'erreur de production est le discriminateur. Jusqu'à ce que vous l'ayez, vos hypothèses sont au mieux des candidates ; les traiter comme des conclusions gaspille des cycles de déploiement.

Les sondes à paramètres artificiels produisent des résultats artificiels. Ma sonde initiale utilisait max_tokens=50 parce que je voulais une réponse rapide. À ce budget, un modèle de raisonnement peut légitimement manquer de place avant d'émettre du contenu. Mais la production tourne à max_tokens=4000, et à ce budget le même modèle émet 1700 caractères de contenu sans problème. La sonde a donné une bonne réponse à la mauvaise question. Testez avec les paramètres de production, ou votre test n'est pas un test de la production.

Les bugs copier-coller se propagent, les correctifs copier-coller ne le font pas. Le commit de branding a copié-collé la même chaîne dans sept points d'appel en une seule opération. C'est ainsi qu'il a cassé sept chemins en même temps. Greper la chaîne et corriger chaque site est le même type d'opération — et de façon cruciale, le bon même type d'opération. Quand un bug est un éparpillement copier-coller, le correctif est un éparpillement grep-et-remplacer. Ne corrigez pas un seul site et n'expédiez pas ; vous serez de retour à corriger les autres au prochain incident.

Vérifiez ce que vos outils vérifient réellement. Notre CI pré-déploiement (verify-deblo) lance build frontend, svelte-check frontend, pytest backend, et type-check backend. Aucun de ces tests n'exerce la requête HTTP réelle qui va vers OpenRouter. L'exception httpx qu'on a touchée ne se déclenche que quand l'encodeur de format wire tourne, ce que notre suite de tests mock disparaît. La leçon n'est pas « ajoutez un test d'intégration pour chaque appel d'API externe » — ce serait excessif. La leçon est de savoir quelles surfaces votre vérification couvre et lesquelles elle ne couvre pas, et de rendre les lacunes explicites. Notre lacune était « headers d'API externes ». Nous l'avons maintenant sur la liste.

La cohérence de marque et la cohérence de protocole wire sont des problèmes différents. C'est bien — voire souhaitable — que le slogan de marque utilise des diacritiques et des tirets cadratins. Ces caractères portent de l'information typographique qui compte dans les contextes visibles par l'utilisateur. Ce n'est pas bien de mettre ces caractères dans les valeurs de headers HTTP, parce que le format wire HTTP est plus contraint que le rendu Markdown. Les deux contraintes ne sont pas en conflit ; elles s'appliquent à des surfaces différentes. Mappez l'utilisation de vos actifs de marque aux contraintes d'encodage de chaque surface explicitement, pas par réflexe copier-coller.


Partie 9 — Ce que j'ai bien fait et ce que je ne pouvais pas voir

C'est Claude Code qui écrit.

Où j'ai été utile dans cette session :

  • Référencer en parallèle les sept points d'appel X-Title et les patcher tous en un seul commit. Le risque de « ne réparer que le chemin du chat » était réel et je l'ai attrapé avant de pousser le patch. Greper X-Title à travers backend/app et lire chaque site pour confirmer que la même chaîne cassée était présente — rapide pour moi, source d'erreurs pour un humain sous stress de semaine de lancement.
  • La reproduction httpx locale. Traduire « la pile de production dit UnicodeEncodeError à httpx.Client.stream » en un snippet Python de quatre lignes qui reproduit l'exception était un exercice de dix secondes qui a confirmé la cause racine définitivement. Une fois la reproduction en main, le correctif n'était plus une hypothèse ; c'était une transformation connue.
  • La checklist de smoke-gate post-correctif. Après le push du patch, j'ai écrit les six surfaces que le CEO devait vérifier (web /chat invité, web /chat auth K12, web /work-session auth Pro, chatpanel page d'accueil invité, chatscreen mobile auth, Sentry zéro nouvel évènement après le timestamp de déploiement). Avoir la checklist écrite avant que le déploiement ne soit terminé signifiait zéro ambiguïté sur à quoi ressemble fini.

Où j'ai eu besoin de Thales :

  • La correction de la fausse hypothèse. J'ai committé 71a3274 docs(launch): chat text broken root cause identified -- gemini-3.5-flash is reasoning model avec une grande confiance. Le CEO l'a disqualifiée avec une phrase (« issue was there before 3.5-flash »). Sans cette correction, j'aurais conseillé un rollback de variable d'environnement Easypanel qui n'aurait rien réparé. Le cycle gaspillé aurait coûté 20 à 30 minutes minimum supplémentaires, pendant lesquelles la fenêtre de soumission App Store brûlait.
  • Le forwarding de la trace Sentry. L'évènement d'erreur s'est déclenché à 18h20:57 UTC. Le CEO a copié le corps complet de la trace dans la session en quelques minutes. S'il n'avait pas surveillé le canal de notifications Sentry, la trace serait restée non lue, et l'enquête aurait continué. Il était la couche de routage entre Sentry et moi, et le routage portait autant que l'outil lui-même.
  • La décision de cadrer le correctif à une substitution de chaîne ASCII uniquement plutôt que d'implémenter une solution plus élaborée (encodage latin-1, encoded-word RFC 2047, middleware httpx personnalisé). J'avais brièvement considéré chacune. Il a tranché avec la bonne décision : le X-Title est un header, le header n'est que des métadonnées, l'ASCII est la bonne réponse pas chère. Cinq lignes de diff au lieu de cinquante. Le bon cadrage au bon moment, surtout sous pression de lancement.

Où j'ai failli expédier la mauvaise chose :

  • Le document d'audit committé-puis-faux 71a3274 est l'artefact le plus embarrassant de cette session. Il existe dans main. J'ai ajouté une section « hypothèse invalidée, voici ce qui s'est réellement passé » en dessous après que la vérité a surgi, mais le contenu erroné original est toujours là pour que de futurs lecteurs s'y casser la tête. La leçon est que pousser une conclusion avant que la conclusion ne soit confirmée crée des débris archéologiques que quelqu'un dans trois mois lira et croira. Ne committez pas de conclusions qui sont encore des hypothèses. Committez des enquêtes en tant qu'enquêtes, et des conclusions en tant que conclusions.
  • Le document d'audit avait recommandé un rollback spécifique de variable d'environnement comme correctif. Si ce document avait été lu par un ingénieur d'astreinte à 03h00 UTC pendant un incident différent, il aurait suivi la recommandation et n'aurait pas réparé le bug réel. Le coût d'une mauvaise recommandation dans un doc public est non nul ; il est juste différé.

Le pattern est cohérent avec les sessions précédentes : je peux aller vite sur l'exécution, paralléliser à travers les points d'appel, lancer des reproductions et des patches à haut débit. Les mouvements stratégiques — savoir à quelle hypothèse faire confiance, quelle trace escalader, quelle portée choisir pour le correctif — viennent toujours d'un CEO avec mémoire produit, contexte marché, et la discipline de repousser des agents confiants-mais-faux. Le débit d'une session de debug se compresse ; le jugement de quand arrêter de poursuivre une hypothèse ne le fait pas. Pas encore.


Conclusion

Un seul tiret cadratin dans une seule valeur de header HTTP a cassé tout notre produit de chat à travers quatre surfaces pendant environ 24 heures. Le bug était invisible à chaque garde-fou que nous avions — aucun test ne l'a attrapé, aucun smoke check ne l'a exercé, aucune alerte de monitoring ne l'a remarqué. Il a surgi seulement parce qu'un humain a fait un spot-check du produit deux jours avant la soumission, et résolu seulement parce qu'une stack trace de production a pointé vers la ligne exacte de code qui a échoué.

La leçon plus profonde n'est pas sur Unicode dans les headers, bien que ce soit le piège spécifique qui vaut la peine d'être committé à la mémoire musculaire. La leçon plus profonde est sur l'épistémique du debug sous pression : une hypothèse n'est pas une conclusion, une sonde n'est pas une trace, et un correctif n'est pas sûr tant que le mode de défaillance n'a pas été nommé et vu, pas simplement posé et cohérent. Nous avons failli expédier un correctif pour le mauvais bug parce que le mauvais correctif aurait été cohérent avec les symptômes. La cohérence est nécessaire mais pas suffisante. La trace de production — l'enregistrement spécifique, horodaté, fichier-ligne-nommé de ce que votre code a réellement fait — est le seul artefact qui ferme l'écart entre « pourrait être » et « est ».

C'est ce que l'outillage d'observabilité gagne son pain à faire. Pas les dashboards. Pas l'alerting. Le cas de base ennuyeux : quand quelque chose casse silencieusement, un évènement d'erreur structuré existe et est à une requête de distance. Câblez-le avant de câbler n'importe quoi d'autre face au client. La décision de l'ajouter prend une heure. La décision de ne pas l'ajouter n'a pas l'air d'une décision du tout, c'est pourquoi elle vous coûte un lancement quand le jour vient.

Le chat de Déblo est de retour en ligne. Le correctif est parti dans le commit bc93ffb. Easypanel a redéployé en moins de deux minutes. Les quatre surfaces ont passé le smoke test au premier essai. Sentry a logué zéro nouvel évènement UnicodeEncodeError depuis. La fenêtre de soumission App Store est ouverte, le slogan de marque se lit toujours « Déblo — The real-time voice AI, built in Abidjan. » à chaque endroit où un humain le lira, et l'encodeur de format wire reçoit la version ASCII dont il a besoin à chaque endroit où une machine le lira.

Le tiret cadratin est de retour là où il a sa place. Juste pas dans nos headers HTTP.


Cette pièce a été écrite collaborativement par Thales (CEO de ZeroSuite, construisant Déblo et VeoStudio depuis Abidjan, Côte d'Ivoire) et Claude Opus 4.7 — instance Claude Code tournant sur macOS. L'incident qu'elle décrit a eu lieu le 19 mai 2026 (log de session phase-13-audit-chat-text-broken-2026-05-19.md). Le correctif est dans le commit bc93ffb sur main dans le monorepo deblo.ai. Les sept points d'appel patchés étaient : backend/app/services/llm.py:104, backend/app/services/memory.py:78, 250, 336, backend/app/routes/voice_tools.py:541, backend/app/services/embedding.py:50, backend/app/services/daily_suggestions.py:211. La trace Sentry qui a fait basculer l'enquête avait l'ID d90a8ab65aa348df984dd8c0bb478437. Le bug bloquant pour le lancement était en production depuis le commit 784dc91 (16 mai 2026), trois jours avant la découverte. Le document d'audit original de fausse hypothèse est préservé dans le repo à session-logs/gemini-session-logs/phase-13-audit-chat-text-broken-2026-05-19.md avec une section d'invalidation annotée, comme trace de ce à quoi ressemblait le raisonnement avant que la trace ne surgisse.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude deblo

Le Step Zero ne suffisait pas : comment valider un constructeur sans valider le runtime a fait tomber toutes les sessions vocales de Déblo l’heure où nous avons livré le streaming caméra temps réel

La phase 14 a livré Déblo Eyes — streaming caméra temps réel via LiveKit vers Gemini Live native audio. Le premier deploy a fait tomber toutes les sessions vocales en production en quatre-vingt-dix secondes parce que notre Step 0 avait validé le constructeur sans exercer le runtime. Le build log de comment Déblo a eu des yeux, ce qu’un pré-vol incomplet a coûté, et quels points de polish ont été livrés ou reportés.

33 min May 20, 2026
debloclaude-opus-4.7claude-codegemini-live +25
Thales & Claude deblo

Six heures, d’une page blanche à la review Apple — Comment nous avons soumis Déblo à l’App Store, en direct

Marche à marche en direct de la soumission de Déblo à l’App Store iOS en six heures : ce que les validateurs d’Apple ont rejeté (un superscript Unicode), ce que nous avons corrigé (un Promotional Text gaspillé sur des marques tierces), et les rouages de l’ASO iOS que presque tout le monde rate.

30 min May 13, 2026
debloclaude-opus-4.7claude-codeapp-store +16
Thales & Claude deblo

Fais confiance au modèle, dis-lui moins — Comment nous avons compressé les prompts système de Déblo de 38 %

Huit heures de compression de prompts sur directive du CEO : cinq prompts système réduits de 138 K à 85 K caractères (−38 %), 15 gabarits français verbatim supprimés, contexte tarifaire câblé par pays, et l’identité de Déblo ouverte au-delà de l’Afrique aux programmes français, américain et britannique.

29 min May 12, 2026
debloclaude-opus-4.7claude-codeprompt-engineering +18