
Table des matières
Ouvrir table des matières
Des logs verts, un rapport fantôme
Laissez-moi vous raconter la panne la plus propre que j’aie jamais croisée. Pas de stack trace, pas d’alerte, pas de worker qui redémarre en boucle. Des logs impeccables, à INFO, qui disent « envoyé ». Et pourtant : le rapport n’est jamais arrivé.
Le décor, d’abord. Sur le réseau Peppol, chaque fournisseur de services doit déposer périodiquement des rapports d’activité — des statistiques de transactions et d’utilisateurs finaux — auprès de l’opérateur du réseau. C’est public, c’est documenté, et c’est borné dans le temps : le dépôt se fait dans une fenêtre donnée, dans le courant du mois suivant la période concernée. Passé la date limite, le rapport n’est plus traité, et le fournisseur est non conforme à la politique de reporting. Pas de séance de rattrapage.
Autant dire que le job qui envoie ces rapports a une seule mission dans la vie, une fois par mois, et qu’il n’a pas le droit de la rater en silence.
Il l’a ratée. En silence. Et cet article est l’autopsie complète : comment trois couches logicielles ont pu dire « succès » chacune de leur côté pendant que la seule chose qui comptait — la remise effective — échouait, et surtout le principe qui a rendu cette panne structurellement impossible ensuite. Spoiler : « remis » ne se définit que par la preuve que le protocole fournit, jamais par le booléen d’une couche intermédiaire.
L’incident : un 502 que personne n’a vu
Le jour du dépôt mensuel, le job se réveille, construit ses deux rapports, et les envoie. En face, le load balancer du destinataire traverse un mauvais moment et répond HTTP 502 aux deux dépôts.
Jusque-là, rien d’extraordinaire : les 502, ça arrive, c’est même précisément pour ça qu’on écrit des retries. Sauf que voilà ce qui s’est réellement passé, pièce par pièce :
- Les artefacts d’envoi archivés disaient la vérité :
sending success = false, aucun accusé protocolaire reçu. La donnée exacte de l’échec était là, écrite noir sur blanc, dans un fichier que personne ne lit. - Le code qui produisait ces artefacts, lui, loggait « envoyé » à INFO et ne levait rien. Pas un WARN, pas un ERROR. Rien.
- Le retry intégré de la pile d’envoi ne se déclenche que sur une exception de transport. Or un POST qui aboutit à un 502, du point de vue de la couche HTTP, n’est pas une exception : c’est une réponse. La requête est partie, une réponse est revenue, le cycle est complet, tout le monde est content.
- Et le job étant mensuel, il n’y avait aucune seconde passe naturelle. Le prochain réveil ? Le mois suivant, pour la période suivante.
La non-remise a été découverte des jours plus tard. Vous savez comment ? Par l’e-mail de relance de l’opérateur du réseau. C’est le destinataire qui nous a appris que nous n’avions rien envoyé. Un comble pour une plateforme dont le métier est justement de livrer des documents, me direz-vous. Je ne vous le fais pas dire.
Le diagnostic : chaque couche dit vrai, l’ensemble ment
Le premier réflexe, devant une panne pareille, c’est de chercher le menteur. Quelle couche a rapporté un succès qui n’existait pas ?
Eh bien c’est là que ça devient intéressant : personne n’a menti.
L’envoi passait par une pile open source à plusieurs couches — un client bas niveau qui parle le protocole d’échange (AS4, la messagerie sécurisée du réseau), un enrobage d’envoi au-dessus, et un enrobage « support reporting » au-dessus encore. Chaque couche expose son propre indicateur de succès. Et chacune rapportait honnêtement le succès de quelque chose de plus étroit que la remise :
- « J’ai construit le message. » Vrai.
- « J’ai posté le message. » Vrai — le POST est parti, une réponse est revenue.
- « Le flux s’est déroulé sans lever d’exception. » Vrai aussi.
Aucune des trois ne répondait à la seule question qui compte : le destinataire a-t-il accusé réception au sens du protocole ? Le protocole AS4 définit un reçu signé — l’accusé qui prouve que le message est bien arrivé et a été accepté. Ce reçu n’est jamais venu. Mais aucune couche n’avait pour mission de transformer son absence en échec global.
C’est le mécanisme profond de cette panne, et il mérite qu’on s’y arrête : l’ensemble pouvait mentir pendant que chaque partie disait vrai. Chaque booléen était localement honnête. Leur composition implicite — « si personne ne se plaint, c’est que c’est bon » — était globalement fausse.

Si vous avez lu mon article sur l’interblocage qui dormait sous les 16 Kio, vous reconnaîtrez la famille : les pannes silencieuses, celles qu’aucun dashboard ne montre parce qu’on n’a instrumenté que ce qui se passe, jamais ce qui aurait dû se passer. Celle-ci est ma préférée de la famille, parce qu’elle ne repose sur aucun bug. Chaque composant fonctionnait exactement comme documenté.
La correction : un verdict, une cadence, du bruit, une preuve
La tentation, après un incident pareil, c’est le patch local : ajouter un if sur le statut HTTP, logger un ERROR, passer à autre chose. Mais un patch local sur une panne structurelle, c’est repeindre une fissure. Le problème n’était pas un if manquant ; c’était l’absence d’une définition de la remise. Alors on a corrigé la structure, en quatre décisions.
Décision n°1 : un verdict unique, à un seul endroit
Première décision, la plus importante : il n’existe plus qu’une seule définition du succès, écrite à un seul endroit du code. Le succès, c’est : toutes les couches sont vertes et l’accusé protocolaire — le reçu signé que définit AS4 — a été reçu et validé. Tout le reste est un échec, quel que soit le nombre de coches vertes intermédiaires.
On veut du code ! Voici le principe, réécrit en générique :
// Le verdict de remise vit à UN endroit et exige la preuve protocolaire.
type SendReport = {
builtOk: boolean;
postedOk: boolean;
httpStatus: number;
protocolReceipt: { valid: boolean; id: string } | null;
};
type DeliveryVerdict =
| { delivered: true; receiptId: string }
| { delivered: false; reason: string };
function verdictOf(r: SendReport): DeliveryVerdict {
if (!r.builtOk) return { delivered: false, reason: "build failed" };
if (!r.postedOk) return { delivered: false, reason: `post failed (HTTP ${r.httpStatus})` };
if (r.protocolReceipt === null || !r.protocolReceipt.valid) {
return { delivered: false, reason: "no valid protocol receipt" };
}
return { delivered: true, receiptId: r.protocolReceipt.id };
}Ce petit bout de code n’a l’air de rien, mais regardez ce qu’il interdit : il est désormais impossible de conclure « remis » sans tenir le reçu protocolaire en main. Le 502 de notre incident ? Il tombe dans la deuxième branche, échec explicite avec le statut HTTP dans la raison. Un reçu absent, invalide, mal signé ? Troisième branche. Il n’y a plus de chemin de code où l’absence d’erreur se déguise en succès.
Et le raffinement auquel je tiens le plus : ce même verdict est appliqué au rapport qu’on vient d’envoyer et à un rapport archivé qu’on réexamine après coup. La garde automatique qui décide de retenter et l’opérateur humain qui contrôle manuellement appellent la même fonction. Avant, ces deux regards pouvaient diverger — le code disait « envoyé », l’artefact disait « échec ». Maintenant ils ne le peuvent plus, par construction.
Décision n°2 : la cadence fait partie du modèle de panne
Deuxième décision, moins évidente : le problème n’était pas seulement comment on envoie, mais quand.
Un job mensuel qui tire une fois est un système sans seconde chance. Toute sa fiabilité repose sur la réussite d’un instant unique — et le monde extérieur, lui, ne s’engage à rien sur cet instant-là. Un load balancer en maintenance, une fenêtre réseau capricieuse, et voilà : l’unique cartouche est brûlée.
Alors le tir mensuel unique est devenu un cron quotidien sur toute la fenêtre de dépôt autorisée. Chaque jour, le job se réveille, applique le verdict : déjà remis (preuve à l’appui) ? On ne fait rien. Pas encore remis ? On retente. Un 502 un mardi devient un non-événement : mercredi réessaiera.
Avec un garde-fou auquel je tiens : la date limite réglementaire est un plafond dur. Configurer une fenêtre de tir qui déborde la deadline ne « marche pas en mode dégradé » : le job refuse de démarrer. Pourquoi si strict ? Parce que des tentatives après la date limite ne seraient pas de la résilience — le rapport ne serait plus traité de toute façon. Ce serait une apparence de conformité : des logs qui montrent un système qui essaie, pour un résultat nul. Et l’apparence de conformité est pire que l’échec bruyant, puisqu’elle endort tout le monde. C’est la même philosophie que la borne d’échéance de mon article sur les quotas : quand une contrainte vient du réglementaire, elle se câble en dur dans le code, pas en commentaire dans la doc.
Décision n°3 : l’échec devient bruyant — et décidable
Troisième décision : quand le verdict dit « non remis », le système émet une ligne ERROR unique et alertable, qui porte tout ce qu’il faut pour décider sans ouvrir le code : le type de rapport, la période concernée, le statut HTTP rencontré, et le nombre de jours restants dans la fenêtre de dépôt.
Ce dernier champ change la nature de l’alerte. « L’envoi a échoué » est une information ; « l’envoi a échoué et il reste 9 jours » est une décision préparée. L’astreinte qui reçoit ça sait immédiatement si c’est un ticket pour demain matin ou une escalade immédiate. J’insiste : le but d’un log d’erreur n’est pas de documenter la panne, c’est de permettre à un humain de choisir la bonne réaction en une lecture.
Vous noterez le contraste avec la situation de départ : on est passé d’un échec loggé « envoyé » à INFO… à un échec qui dit son nom, son contexte et son urgence. Entre les deux, il n’y a pas une ligne de code héroïque — juste la décision de définir ce qui mérite du bruit.
Décision n°4 : la preuve devient durable
Dernière décision, et découverte un peu honteuse au passage : les artefacts d’envoi — ceux-là mêmes qui contenaient la vérité (sending success = false) pendant que les logs racontaient autre chose — vivaient sur le système de fichiers du conteneur.
Vous voyez le problème venir : un redémarrage, un redéploiement, et pouf. Disparus. Or ces artefacts portaient deux choses précieuses à la fois : la preuve réglementaire de ce qui a été déposé (avec le reçu signé), et l’information « déjà remis » dont dépend désormais la relance quotidienne. Perdre le premier, c’est ne plus pouvoir prouver sa conformité ; perdre le second, c’est un cron quotidien qui ne sait plus s’il doit retenter — donc qui renvoie, ou qui ne renvoie pas, au petit bonheur.
Les artefacts ont donc déménagé vers un stockage objet partagé, écrit par le même port — au sens de l’architecture hexagonale — que celui que lit la garde quotidienne. Un seul chemin d’écriture, un seul chemin de lecture, une preuve qui survit aux conteneurs.
La cerise : le correctif a failli reproduire la panne
Et maintenant, ma partie préférée de toute cette histoire — le moment le plus instructif du chantier.
En écrivant l’archivage vers le stockage objet, le premier jet du code a failli reproduire l’incident d’origine, à l’identique. La bibliothèque cliente du stockage, sur certaines opérations, ne lève pas d’exception en cas d’échec : elle signale l’échec par valeur de retour. Un statut, dans l’objet retourné, que rien ne vous oblige à lire. Et le premier jet… le jetait, précisément.
Relisez la panne d’origine : un 502 encodé dans une réponse que personne n’inspecte, une couche qui dit « je me suis exécutée » pendant que le résultat dit « j’ai échoué ». C’est exactement la même forme : un statut que personne ne lit. Nous étions en train de corriger cette panne en la réinstallant un étage plus bas, dans le code de la correction elle-même.
Bien entendu, le jet a été corrigé avant de partir. Mais j’en garde une méfiance durable : les API qui signalent l’échec par valeur de retour plutôt que par exception sont des pièges à répétition, parce que le compilateur ne vous forcera jamais à lire un champ. Une exception non gérée remonte, crie, laisse une stack trace. Un booléen non lu ne fait rien du tout — c’est sa définition. Quand vous intégrez une dépendance, la question « comment cette bibliothèque me dit-elle que ça a raté ? » mérite dix minutes de lecture de doc avant la première ligne de code. Dix minutes contre un incident silencieux, le calcul est vite fait.
Ce que j’en retiens
Quatre enseignements, du plus spécifique au plus général.
« Remis » se définit par la preuve du protocole, pas par l’absence d’erreur. Si le protocole que vous parlez définit un accusé — reçu AS4, ACK signé, notification de livraison, webhook de confirmation — alors c’est lui le critère de succès, et lui seul. Tout le reste — code HTTP, absence d’exception, booléen d’une couche intermédiaire — est un proxy. Et un proxy peut dire vrai localement et faux globalement, c’est toute cette histoire.
Les retries qui n’écoutent que les exceptions ratent les échecs les plus fréquents. Une réponse HTTP 5xx complète le cycle requête/réponse : pour la couche transport, tout s’est « bien passé ». Si votre politique de retry se déclenche sur catch, elle est aveugle à toute une catégorie d’échecs — probablement la plus courante en production. Décidez du retry sur le verdict métier (« remis ou pas ? »), jamais sur le type technique de l’erreur.
La cadence d’un job fait partie de son modèle de panne. On analyse volontiers le code d’un job — ses entrées, ses erreurs — et presque jamais son calendrier. Pourtant un job mensuel n’a aucune seconde chance naturelle : c’est une propriété de fiabilité, au même titre qu’un point unique de défaillance. S’il porte une obligation datée, sa boucle de retry doit couvrir la fenêtre autorisée et être bornée par la deadline — et le dépassement de cette borne doit être une erreur de configuration qui refuse de démarrer, pas un comportement qui s’exécute pour rien.
Chassez les erreurs-par-valeur-de-retour dans vos dépendances. C’est le piège récurrent de cette histoire, présent au début (la réponse 502 que personne ne lit) comme à la fin (le statut de la bibliothèque de stockage que personne ne lit). Chaque fois que vous adoptez une API, cherchez comment elle échoue. Si c’est par valeur de retour, traitez chaque appel non vérifié comme un bug en sursis.
Épilogue
Récapitulons le voyage : un 502 invisible, trois couches honnêtes composées en un mensonge, un job mensuel sans seconde chance, et la découverte par l’e-mail du destinataire — le pire canal d’alerte du monde. La sortie de crise n’a pas été un patch mais quatre décisions de structure : un verdict unique fondé sur la preuve protocolaire, une cadence quotidienne bornée par la deadline réglementaire, un échec bruyant qui prépare la décision, une preuve durable dans un stockage partagé. Et une cerise : le correctif qui a failli reproduire la panne, parce que le piège des statuts qu’on ne lit pas est partout, y compris dans nos propres corrections.
Rien de bien sorcier, encore une fois. Mais je vous garantis qu’un système passé par ce chantier ne rate plus un dépôt en silence : il peut échouer, bien sûr — le monde extérieur aura toujours des 502 en réserve — mais il échoue fort, tôt, et avec les jours restants affichés. C’est toute la différence entre une panne et un incident.
Si le sujet vous parle, trois lectures dans la même veine : ce que ni les tests unitaires ni les E2E ne voient — une autre panne parfaitement silencieuse, un compteur ne fait pas un quota — où l’on câble aussi une deadline réglementaire en dur, et Peppol pour les développeurs français si vous voulez le décor complet du réseau dont il est question ici.
Et vous, avez-vous déjà été trahi par un booléen de succès qui ne mesurait pas ce que vous croyiez ? Racontez-moi ça en commentaire — je me ferai une joie de vous répondre, surtout si votre panne silencieuse était encore plus sournoise que la mienne.

