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 :
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.