Dans mon article précédent, je racontais comment j'avais donné à une IA un accès SSH en lecture seule à mon infrastructure de production : auditer, documenter, surveiller. La suite logique, c'était de la laisser non plus seulement lire, mais construire.

Alors je l'ai fait. Avec Claude comme co-pilote, j'ai construit une vraie application : une plateforme de matching de prix pour des swaps de taux OTC. Temps réel, WebSocket, authentification SSO, un moteur qui apparie automatiquement les intérêts complémentaires de plusieurs contreparties. FastAPI, asyncio, SQLite. Mise en production avec un broker pilote et une quinzaine de contreparties bancaires derrière lui.

Et ça a marché. Pendant un mois.

Puis le broker m'a écrit : « La session s'est coupée alors qu'il restait du temps à l'horloge. » Et aussi : « Les nominaux d'un trader clignotent, ils apparaissent et disparaissent. » Et encore : « Le timing est bizarre, les sessions sont trop longues. »

Mon horloge s'était mise à mentir. Voici comment j'ai trouvé pourquoi, et ce que ça m'a appris sur le mot « asynchrone ».

Le symptôme : des sessions qui ne finissent pas à l'heure

Le cœur de la plateforme, c'est une session de matching à durée fixe. Vous la configurez pour, disons, dix minutes : un compte à rebours, puis une fenêtre de matching, puis c'est terminé. Tout le monde voit la même horloge.

Sauf que non. En production, une session configurée pour 600 secondes en durait 1202 (le double). Une autre, 1803 (le triple). En remontant l'historique sur une quarantaine de sessions, je n'ai trouvé aucune régularité. J'ai trouvé ça :

1.0×, 1.2×, 1.41×, 1.5×, 2.0×, 2.5×, 2.67×, 3.0×, 3.5×… jusqu'à 5.51×.

Une session censée durer dix minutes pouvait en durer presque une heure. Et personne ne comprenait pourquoi, moi le premier.

La première fausse piste : « c'est le timer »

Le réflexe évident : le code du timer a un bug. Il se ré-arme deux fois, il oublie de s'arrêter, allez savoir.

Je l'ai isolé et testé seul. Compte à rebours de 2 secondes, matching de 3 secondes : exact, une seule transition, aucun double-armement. Le timer était parfait en isolation.

Pire (ou mieux) : en production, le compte à rebours était exact : 30 secondes, pile. C'est seulement la phase de matching qui dérapait. Quelle est la différence ? Pendant le compte à rebours, il ne se passe presque rien. Pendant le matching, ça vit : des ordres arrivent par WebSocket, chaque client interroge l'API toutes les trois secondes, un flux de prix pousse des données plusieurs fois par seconde, et le serveur diffuse l'état à tout le monde en continu.

Le timer n'était pas cassé. Il était affamé.

L'indice qui a tout débloqué : la forme de l'erreur

Voilà le détail qui a fait basculer le diagnostic, et c'est devenu ma règle depuis :

La forme de la distribution de vos erreurs vous dit de quel type de bug il s'agit.

Un bug discret, un timer qui se réinitialise, qui compte deux fois, produit des erreurs discrètes : des multiples entiers propres. ×2, ×3, jamais ×2.67. Or je n'avais pas des multiples entiers. J'avais un continuum : 1.2×, 1.41×, 2.67×, 3.5×, 5.51×. Une rampe continue.

Et une rampe continue, ça ne ressemble pas à un bug logique. Ça ressemble à de la contention : plus il y a de charge, plus c'est lent, proportionnellement. Le facteur d'étirement suivait l'activité de la session. À partir de là, je ne cherchais plus un bug dans le timer. Je cherchais ce qui bloquait la boucle.

La cause : un seul client lent bloquait tout le monde

asyncio tourne sur un seul thread. Tout le serveur, le timer, les ordres, les diffusions, les battements de cœur des WebSockets, partage une unique boucle d'événements. Cette boucle est coopérative : tant qu'un morceau de code ne « rend pas la main » (avec un await qui cède réellement), rien d'autre ne s'exécute.

Mon timer comptait des await asyncio.sleep(1). En théorie, chaque tour = une seconde. En pratique, sleep(1) ne reprend que quand la boucle a le temps de le rappeler. Si la boucle est occupée ailleurs, chaque « seconde » du timer dure une seconde plus le retard. Comptez assez de tours en retard, et votre session de dix minutes en dure cinquante.

Le coupable principal : la fonction de diffusion. Elle était bien déclarée async, mais elle envoyait à chaque client séquentiellement, l'un après l'autre, sans timeout, et elle était attendue (await) à chaque tick du timer et à chaque trade.

Il suffisait d'un seul client lent ou à moitié mort, un onglet figé, un token expiré, un buffer TCP plein côté réseau, pour que l'envoi vers ce client bloque toute la boucle de diffusion. Et tant que cette boucle bloquait, le tick du timer attendait. Et les battements de cœur de tous les autres clients attendaient aussi.

Un client cassé, et toute la salle ralentit.

Le moment où tout s'est recollé

La beauté d'une vraie cause racine, c'est qu'elle n'explique pas un symptôme. Elle les explique tous.

  • « La session est trop longue » → le timer est étiré par la famine.
  • « Coupé alors qu'il restait du temps » → le battement de cœur WebSocket arrivait en retard ; le navigateur croyait la connexion morte, se déconnectait ; après un délai de grâce, le client était éjecté, l'horloge figée sur son dernier tick.
  • « Les nominaux clignotent » → les diffusions arrivaient en retard et désordonnées.
  • La tempête de « token expiré » dans les logs → les requêtes lentes poussaient les clients à réessayer en boucle, ce qui chargeait encore plus la boucle.

Cinq plaintes différentes, une seule maladie. Je n'avais pas cinq bugs. J'en avais un, qui se présentait sous cinq masques.

Le fix, en deux temps

1. Une horloge murale, pas un compteur de tours. J'ai arrêté de compter des sleep(1). Au début de chaque phase, je fige une échéance absolue : deadline = time.monotonic() + durée. À chaque tick, le temps restant est ceil(deadline - maintenant), et la phase se termine dès que maintenant >= deadline. Si la boucle est saturée et qu'un tick arrive en retard, l'échéance ne bouge pas : la session finit à l'heure réelle configurée, point. Le retard de la boucle n'étire plus le temps, il le rattrape. (Et c'est time.monotonic(), pas datetime.now() : on veut une horloge qui ne recule jamais, insensible aux ajustements NTP.)

2. Une diffusion concurrente, bornée par client. J'ai réécrit la diffusion pour envoyer à tous les clients en parallèle (asyncio.gather), chaque envoi enveloppé dans un asyncio.wait_for(..., timeout=2s). Un client lent ? Son message est sauté pour ce tour, pas déconnecté, il est lent, pas mort. Un client réellement mort ? Retiré. Résultat : la diffusion est bornée à ~2 secondes dans le pire cas, au lieu de la somme de tous les envois.

La leçon tient en une phrase : async def ne rend rien concurrent. Une boucle de await envoyer() reste séquentielle. La concurrence, c'est gather.

La preuve avant de crier victoire

Je ne voulais pas « déployer et croiser les doigts ». J'ai écrit un test qui injecte une seconde de blocage synchrone à chaque tick, la famine, reproduite à la demande.

  • Avant le fix (compteur de tours) : matching ×2.
  • Après le fix (horloge murale) : durée exacte, peu importe le blocage injecté.

Et un second test avec trois clients, un rapide, un lent (5 s), un mort, pour vérifier que la diffusion bornée se comporte bien : le lent est sauté, le mort est retiré, le rapide n'attend personne. Les deux tests passent. Ensuite j'ai déployé.

Ce que j'en retiens

« Asynchrone » ne veut pas dire « concurrent ». C'est l'erreur conceptuelle au cœur de toute l'histoire. asyncio vous donne la possibilité de la concurrence ; il ne vous la donne pas gratuitement. Une boucle qui await chaque opération l'une après l'autre est aussi séquentielle qu'une boucle for classique, elle bloque juste poliment.

Ne laissez jamais la lenteur d'un client toucher un état partagé. Tout ce qui parle au réseau doit avoir un timeout. La backpressure d'un seul pair ne doit jamais pouvoir prendre en otage l'horloge de tout le monde.

Mesurez le temps avec une horloge, pas avec des itérations. Compter des sleep(1), c'est parier que la boucle n'est jamais en retard. Elle l'est toujours, un jour.

La forme de vos erreurs est un diagnostic. Des multiples entiers propres → cherchez un bug logique discret. Un continuum → cherchez de la contention. Cette seule distinction m'a fait gagner des jours.

Et le méta, parce que c'est le fil de ce blog : un Head of IT qui n'est pas développeur de métier a mis cette plateforme en production avec une IA. L'IA a écrit une grande partie du code. Mais ce bug-là, ce n'est pas de la génération de code qui l'a résolu, c'est le raisonnement : remonter le continuum, formuler l'hypothèse de famine, choisir entre « horloge murale » et « compteur ». L'IA a été un excellent partenaire pour instrumenter, mesurer et écrire le correctif une fois la cause comprise. La compréhension, elle, est restée humaine.

C'est exactement ce que je disais il y a trois mois. L'IA ne remplace pas l'ingénieur. Elle lui rend le temps de faire le vrai travail : comprendre.