La trahison du chiffre
Vendredi 15 mai, 16 h 13. L'alerte Sentry remonte sur le téléphone. La première réinscrite Phase 1 attend devant l'écran de paiement, son nom est en haut de mon onglet. Je pose la canette, je rouvre l'écran. La tasse à tête de Françoise, sur le poste d'à côté, capte un reflet jaune que je remarque sans le regarder. La stack trace tient en plein écran.
Le stack trace s'ouvre, neuf champs sur dix à null, et un chiffre que je n'ai pas vu venir.
type = "StripeConnectionError"
message = "An error occurred with our connection to Stripe."
code = null
statusCode = null
requestId = null
duration = 9 ms
Neuf millisecondes. Sur une route Vercel en région Paris, un DNS résout en quarante millisecondes, un handshake TLS coûte cent à deux cents. Neuf millisecondes, ce n'est pas un appel réseau qui a échoué. C'est un appel réseau qui n'a jamais eu lieu. Le SDK n'est pas arrivé jusqu'à la fibre.
L'instinct propose immédiatement trois patchs. Timeout serverless Vercel — j'ajoute maxDuration, je redéploie. Clé révoquée — je vais la rouler. Compte Stripe restreint après le passage en mode live — j'ouvre un ticket support. Ces trois hypothèses sont plausibles. Aucune des trois n'est falsifiable par le symptôme seul, et c'est précisément ce qui les rend dangereuses : chacune ouvre un cycle de quinze à trente minutes avec rollback à la fin si elle se trompe. Multiplié par trois, on tient une demi-journée perdue avec la cliente toujours en train de cliquer.
Je n'ai pas le temps. Une réinscrite attend.
Quatre tests, dans l'ordre
Je connais la classe d'incident — « preview marche, prod casse », ou son symétrique. La règle, pour cette classe, c'est qu'on ne corrige rien tant qu'on n'a pas discriminé les couches. Quatre tests, exécutés dans l'ordre. Chacun élimine une famille d'hypothèses, pas une hypothèse isolée. Et chacun est conçu pour réfuter ce qu'il vient interroger — parce qu'un test qui cherche à confirmer trouve toujours, par sélection, ce qu'il cherche.
Test 1 — reproduire dans l'environnement témoin. Je relance le même tunnel en preview, avec la clé sk_test_. Le Checkout s'ouvre en trois cent quatorze millisecondes, propre. Conséquence immédiate : ce n'est pas le code applicatif qui est en cause. Le code est strictement identique entre preview et prod ; seules varient les variables d'environnement, le plan Vercel sur cette région, et la clé Stripe. Trois variables seulement, et le brouillard se densifie déjà du bon côté.
Test 2 — endpoint minimal. Je déploie une route Vercel d'une seule ligne utile, runtime nodejs forcé explicitement, qui appelle stripe.balance.retrieve() — le call SDK le plus dépouillé possible, sans line_items, sans metadata, sans idempotencyKey, sans rien de la complexité métier du Checkout. En preview : deux cents millisecondes, succès. En prod : neuf millisecondes, le même StripeConnectionError. Conséquence : le problème n'est pas dans les paramètres du Checkout. Il n'est pas non plus dans une logique métier qui aurait dérapé. Le SDK lui-même crashe au plus simple appel possible.
Test 3 — bypasser la dépendance suspecte. Au lieu d'appeler le SDK, je fetch directement https://api.stripe.com/v1/balance avec l'en-tête Authorization: Bearer sk_live_…. En prod, sur la même route Vercel : deux cents OK, trois cent quatorze millisecondes, payload qui confirme livemode: true. Conséquence — et c'est la conséquence la plus précieuse — l'infrastructure réseau Vercel→Stripe fonctionne. C'est strictement le SDK qui ne franchit pas la couche réseau. Ni Vercel, ni Cloudflare en amont, ni Stripe en aval ne sont en cause.
Niran passe derrière l'épaule à ce moment-là, lit la sortie curl sur le terminal. Il prononce trois mots, « c'est pas le réseau », et repart vers son poste sans relever davantage. Économie de gestes.
Test 4 — lire le source au point d'erreur exact. Le stack trace m'indique node_modules/stripe/esm/RequestSender.js:400:41. J'ouvre le fichier dans le repo Vercel déployé. Ligne quatre cents, c'est le .catch(error) de la promise du HTTP client interne. Le SDK attendait une réponse de son propre client interne, et son propre client interne a rejeté immédiatement, avant même d'émettre une requête. Je remonte dans le package.json de la lib :
"exports": {
"worker": {
"import": "./esm/stripe.esm.worker.js",
"require": "./cjs/stripe.cjs.worker.js"
},
"default": {
"import": {
"default": "./esm/stripe.esm.node.js"
}
}
}
Voilà ce qui se passait. Le package.json de stripe^22 déclare un export conditionnel "worker" destiné aux environnements Cloudflare Workers. Le bundler Next 16, malgré export const runtime = 'nodejs' explicitement déclaré au sommet de la route, résout cette condition "worker" au moment du bundle des Server Actions en production. Le bundle charge alors stripe.esm.worker.js, une variante du SDK qui repose sur le fetch standard du runtime Worker et qui n'a pas le HTTP client Node natif. Cette variante, exécutée sur le runtime Node de Vercel, échoue silencieusement à l'initialisation de son HTTP client — pour une raison probablement liée à une feature Cloudflare absente du runtime Vercel — et la promise du tout premier request se rejette dans la milliseconde qui suit.
L'hypothèse n'est pas confirmée à cent pour cent. Mais elle est cohérente avec les trois faits matériels accumulés : l'écart prod/preview qui dépend du contexte de bundle, l'échec en neuf millisecondes synchrone sans réseau, l'absence totale de requestId parce qu'aucune requête n'a jamais été émise.
Le workaround écrit, puis le ROI compté
En vingt minutes, le diagnostic tient. En quarante minutes de plus, le helper lib/stripe-fetch.ts est en production sur six surfaces — Checkout Sessions, retrieve PaymentIntent, retrieve BalanceTransaction, create off_session PaymentIntent, retrieve Checkout Session, et Payment Links de facturation.
// lib/stripe-fetch.ts
export async function stripePost<T = unknown>(
path: string,
params: Record<string, ParamValue>,
options?: { idempotencyKey?: string },
): Promise<T> {
const headers: Record<string, string> = {
Authorization: `Bearer ${getKey()}`,
'Content-Type': 'application/x-www-form-urlencoded',
}
if (options?.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey
const res = await fetch(`https://api.stripe.com/v1/${path}`, {
method: 'POST',
headers,
body: encodeParams(params),
})
return parseStripeResponse<T>(res)
}
// app/inscription/actions.ts::finaliserReinscription (excerpt)
const stripeRes = await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${stripeKey}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: encodeParams(checkoutParams),
})
À 17h35, je relance le tunnel en prod avec une fausse fiche : la session Checkout s'ouvre, livemode confirmé, méthodes carte plus Link plus Google Pay. La cliente de 16h13 reçoit l'email d'excuse et le nouveau lien dans la foulée. Phase 2 du lundi 19/05, soixante-cinq anciens à relancer, débloquée matériellement.
Si j'avais commencé par patcher le timeout, j'aurais redéployé, attendu cinq minutes, retesté, constaté l'échec, retiré le patch, attendu encore cinq minutes : un cycle d'environ vingt minutes. À ajouter à la roulette de la clé — quinze minutes le temps de générer, propager, attendre l'invalidation des caches Vercel. Et au ticket support Stripe : entre deux et quarante-huit heures, opaques, pendant que la production saigne. Comparé à ces trois patchs, le protocole tient en moins de trente minutes et débouche sur la vraie cause — pas sur un voisin de la vraie cause.
La généralisation, sobrement
Le protocole vaut pour toute classe « le même code se comporte différemment selon l'environnement ». Les symptômes-déclencheurs que je remets désormais en tête de file : StripeConnectionError, ECONNREFUSED ou ETIMEDOUT au runtime mais pas au build, Module not found qui n'apparaît qu'en prod, ou pire encore — un try / catch silencieux qui retourne un fallback trompeur et fait croire que la branche principale a réussi. Quatre tests, dans le même ordre. Témoin, minimal, bypass, source.
Le protocole ne vaut pas pour les bugs métier — une query SQL fausse, un if mal calibré, une logique applicative qui rend le mauvais résultat. Là, la cause est dans le code que vous avez écrit, et c'est un grep ciblé qui la trouve, pas une discrimination de couches.
Coda
On ne corrige pas un défaut de cuisson en regardant la pièce. On regarde la courbe du four, le poste de gaz, le tirage de la cheminée. Le code applicatif, c'est la pièce — il sort tel qu'on l'a façonné. Les quatre tests interrogent le four. Chacun éteint une lampe possible jusqu'à ce qu'il n'en reste qu'une, qui est la bonne. Trente minutes au lieu d'une demi-journée, et surtout : la certitude d'avoir patché là où il fallait, pas dans un voisinage flatteur qui laisse le vrai bug dormir jusqu'au prochain incident.
Le protocole 4 tests est l'instance applicative de la règle R4 Falsify before fix du Counterpart Toolkit, sur la classe d'incident « bug d'environnement ». La règle générale demande trois sondes conçues pour réfuter ; cette classe-ci en mérite quatre, dans un ordre figé. C'est tout. Mais ce tout, le jour où la production saigne, vaut la demi-journée qu'il vous fait gagner.
Counterpart Toolkit v0.7, R4 *Falsify before fix. Référence canonique : github.com/michelfaure/doctrine-counterpart. Scènes recomposées, prénoms calibrés sur les fiches cast récurrentes de la série.*
Top comments (0)