zoe@dev:~$PID 1337
zoe@dev:~$ cat ~/blog/shipping-an-ai-tool-fr.mdx

Mettre un outil d'IA en production : tout ce qui a cassé

2026-07-20·Zoe Lin·6 min read·#nextjs#vercel#seo#security#postmortem
EN中文日本語한국어EspañolFrançaisDeutschPortuguês

Construire ~/tools/paper-revision a été la partie facile. Faire en sorte qu'il se comporte en production comme en local a pris beaucoup plus de temps, et la plupart des bugs ne venaient pas du tout de la partie IA — ils se cachaient dans la plomberie qui l'entourait. Voici ce qui a vraiment cassé.

Le 502 silencieux : les connexions SSE ont besoin d'un heartbeat

Les reviews longues effectuées avec une clé Anthropic BYOK échouaient parfois après ~100 secondes — aucun message d'erreur, juste une connexion morte. Les logs de Vercel affichaient un 502 tout propre, sans rien qui pointe vers la cause réelle.

Le vrai problème : un modèle peut rester silencieux un moment en pleine génération (une longue phase de « réflexion » interne avant le premier token visible), et si le flux de réponse ne renvoie aucun octet pendant trop longtemps, un intermédiaire quelconque entre le client et le modèle décide que la connexion est inactive et la tue. La solution a été un heartbeat — un caractère invisible écrit dans le flux à intervalle fixe pour que les octets ne cessent jamais de circuler :

providers.ts
const HEARTBEAT_MS = 15_000
const HEARTBEAT_CHAR = '' // espace de largeur nulle, invisible une fois le markdown rendu
 
const result = await Promise.race([pending, heartbeat])
if (result === 'heartbeat') {
  controller.enqueue(encoder.encode(HEARTBEAT_CHAR))
  continue
}

En creusant ce problème, on a aussi mis au jour un bug pire, caché juste derrière : le gestionnaire d'abort attrapait toutes les exceptions nommées AbortError et les traitait comme une annulation volontaire de l'utilisateur — y compris celles qui venaient simplement du réseau qui mourait tout seul. De vrais échecs étaient silencieusement avalés. Corriger ça a demandé de suivre si l'utilisateur avait vraiment cliqué sur « abort » avant de faire confiance au nom de l'erreur.

BYOK (apportez votre propre clé), sans se tirer une balle dans le pied

L'offre gratuite tourne sur un modèle hébergé, avec une limitation de débit ; les utilisateurs BYOK paient avec leur propre clé et sont censés y échapper complètement — sauf que ce n'était pas le cas dans la première version, et les requêtes BYOK grignotaient elles aussi le quota de l'offre gratuite. C'est un rapport de bug tout ce qu'il y a d'honnête (« mais j'utilise ma propre clé, et pourtant je suis limité ») qui a fait remonter le problème.

L'autre leçon côté BYOK était proactive plutôt que réactive : plafonner la liste des modèles autorisés à des paliers de prix raisonnables, pour qu'une requête ne puisse pas atterrir par accident sur le modèle le plus cher disponible, et plafonner le nombre de tokens en sortie quel que soit le provider. Rien de tout ça n'empêche un utilisateur de mal dépenser son propre argent, mais ça évite que notre code en soit la cause.

Un favicon que Google Search refusait d'afficher

Le favicon du site s'affichait parfaitement dans chaque onglet de navigateur, mais n'apparaissait tout simplement jamais à côté de l'URL dans les résultats Google Search. La cause était presque gênante, tant elle était indirecte : la convention d'icône générique de Next.js ajoute un hash de contenu à l'URL du fichier — /icon.png?d72260d... — et ce hash change à chaque fois que les octets du fichier changent. Les consignes de Google elles-mêmes sont explicites sur ce mode d'échec précis : « l'URL du favicon doit être stable — ne la changez pas fréquemment. » Chaque modification de cette icône réinitialisait discrètement toute la confiance que Google avait accumulée sur l'ancienne URL.

Next.js fait un cas à part pour un seul et unique nom de fichier — favicon.ico échappe entièrement au hash de contenu et obtient une URL stable de façon permanente. Emballer la même image dans un conteneur ICO minimal et passer à ce nom de fichier a suffi à tout corriger. Ça valait aussi le coup de vérifier la configuration du domaine au passage : pour ce qui est du favicon, Google traite chaque nom d'hôte différent comme un « site » entièrement distinct, donc www. et le domaine *.vercel.app par défaut ont tous les deux reçu des redirections permanentes vers l'unique nom d'hôte canonique.

Cloudflare Turnstile ne se rend qu'une seule fois

Le mode de rendu implicite de Turnstile scanne la page à la recherche d'éléments .cf-turnstile exactement une fois, au tout premier chargement de son script. C'est invisible sur un site multi-pages traditionnel, où chaque navigation recharge le script à zéro — mais ici, c'est une SPA, et l'état du formulaire de review survit délibérément à la navigation côté client, pour qu'une review en cours ne soit pas abandonnée. Si on navigue ailleurs puis qu'on revient, le point de montage du widget est un tout nouveau nœud DOM que le script déjà chargé ne rescanne jamais. Le token devient silencieusement undefined, ce qui — après l'ajout d'une vérification côté serveur sur l'endpoint de partage — a fait échouer, sans la moindre explication visible, chaque tentative de partage suivant un aller-retour de navigation.

La solution, c'est l'API de rendu explicite de Turnstile : charger le script une seule fois, puis appeler soi-même turnstile.render() à chaque montage et turnstile.remove() à chaque démontage, plutôt que de compter sur son scan automatique à usage unique.

Dev sous Windows, prod sous Linux

Deux bugs distincts, une seule et même cause racine : développer sous Windows et déployer sur le runtime Linux de Vercel, ce n'est pas le même environnement, et les bibliothèques PDF/image sont exactement l'endroit où cet écart se voit. pdf-parse plantait en production avec une erreur DOMMatrix qui ne se reproduisait jamais en local — passer à une bibliothèque PDF compatible serverless (unpdf) a réglé le problème net. Séparément, la génération d'images de next/og plantait au chargement des polices, un problème remonté jusqu'à un chemin de fichier Windows contenant des espaces sur lequel le parsing d'URL de la bibliothèque s'étranglait. Aucun des deux bugs n'existait du point de vue de npm run dev en local ; les deux étaient reproductibles à 100 % sur chaque déploiement.

Ce qu'une revue de sécurité détecte vraiment

Un passage complet sur toute la base de code a mis au jour une poignée de problèmes bien réels, quoique discrets : un endpoint de partage de snapshots sans la moindre vérification anti-bot — techniquement ouvert à n'importe qui pour publier du texte arbitraire sur une URL publique ; une limitation de débit facturée avant même de vérifier si le fichier uploadé pouvait être parsé, si bien qu'un upload mal formé grillait le quota d'un utilisateur pour une review qui n'allait de toute façon jamais avoir lieu ; un gestionnaire de zip qui décompressait une entrée d'archive avant de vérifier sa taille déclarée, exactement la forme que prend une vulnérabilité de type zip bomb ; et un bouton de partage qui créait silencieusement un snapshot en double si on naviguait ailleurs puis qu'on cliquait de nouveau dessus, parce que l'état « a-t-on déjà un lien » vivait dans un composant qui venait tout juste d'être détruit et recréé.

Rien de tout ça n'était exotique. C'était le genre de bug qui ne se révèle que lorsque quelqu'un lit vraiment le code en cherchant ce qui pourrait mal tourner, plutôt que de simplement vérifier que le chemin nominal fonctionne.

Mot de la fin

Chaque bug de cette liste est parti en production, a fonctionné, et avait l'air fini — jusqu'à ce qu'une entrée précise, une navigation précise, ou un environnement d'hébergement précis prouve le contraire. Une infrastructure ennuyeuse, vérifiée avec soin, vaut mieux qu'une infrastructure ingénieuse qui n'a jamais été mise à l'épreuve de la production. exit 0.

cd ~/blog ←