A developer reports a production incident on Next.js 16 where calls to Stripe fail with a “StripeConnectionError” after 9 milliseconds, while the same code works in a preview environment. The error includes null values for fields such as code, statusCode, and requestId, suggesting the request never reaches Stripe. To isolate the cause, the developer uses a four-step “falsify before fix” approach: first reproducing the issue in preview (works), then deploying a minimal Vercel endpoint that calls stripe.balance.retrieve() (fails in production with the same 9ms error), then bypassing the Stripe SDK by calling Stripe’s API directly with fetch (succeeds and returns a successful livemode response), and finally inspecting the Stripe SDK source at the failure point. The developer concludes the Next 16 bundling process selects a conditional “worker” export from stripe^22, loading stripe.esm.worker.js intended for Cloudflare Workers. That worker-oriented variant fails when run under Vercel’s Node runtime, causing an immediate promise rejection before any network request is made. As a workaround, the developer replaces SDK usage with direct fetches to api.stripe.com for several Stripe endpoints, unblocking payment flows.
Developer traces 9ms StripeConnectionError on Next.js 16 to incorrect Stripe SDK worker bundling
A developer reports a production incident on Next.js 16 where calls to Stripe fail with a “StripeConnectionError” after 9 milliseconds, while the same code works in a preview environment. The error in...
- The error is a “StripeConnectionError” that fails in production in about 9 milliseconds and includes no requestId, implying no outbound request is made.
- The same Stripe integration works in a preview environment, pointing to an environment/build-time difference rather than application logic.
- A minimal SDK call (stripe.balance.retrieve()) succeeds in preview and fails in production with the same 9ms error.
- Direct calls to Stripe’s REST API using fetch (bypassing the Stripe SDK) succeed in production, indicating Vercel-to-Stripe connectivity is working.
- The developer attributes the cause to Next.js 16 bundling selecting the Stripe SDK’s conditional “worker” export, loading a worker-targeted module that fails under Vercel’s Node runtime.
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.*
2 hours agoThe number that lied Friday May 15, 4:13 PM. The Sentry alert pings on my phone. The first Phase 1 re-enrolling student waits in front of the payment screen, her name at the top of my tab. I put down the can, I reopen the screen. The mug with Françoise's face on it, on the desk next door, catches a yellow reflection I notice without looking at. The stack trace fills the screen. The stack trace opens, nine fields out of ten at null, and a number I didn't see coming. type = "StripeConnectionError" message = "An error occurred with our connection to Stripe." code = null statusCode = null requestId = null duration = 9 ms Nine milliseconds. On a Vercel route in Paris region, DNS resolves in forty ms, a TLS handshake costs one to two hundred. Nine milliseconds isn't a network call that failed. It's a network call that never happened. The SDK didn't reach the wire. Instinct immediately offers three patches. Vercel serverless timeout — I add maxDuration, redeploy. Revoked key — I'll rotate it. Stripe account restricted after the live switch — I open a support ticket. These three hypotheses are plausible. None of the three is falsifiable from the symptom alone, and that's precisely what makes them dangerous: each opens a fifteen-to-thirty-minute cycle with rollback at the end if it's wrong. Multiplied by three, half a day lost with the customer still clicking. I don't have time. A student is waiting. Four tests, in order I know the incident class — "preview works, prod breaks", or its mirror. The rule for this class is that you fix nothing until you've discriminated the layers. Four tests, executed in order. Each eliminates a family of hypotheses, not an isolated hypothesis. And each is designed to refute what it interrogates — because a test that seeks to confirm always finds, by selection, what it's looking for. Test 1 — reproduce in the witness environment. I rerun the same funnel in preview, with the sk_test_ key. Checkout opens in three hundred fourteen milliseconds, clean. Immediate consequence: it's not the application code. The code is strictly identical between preview and prod; only environment variables, the Vercel plan on that region, and the Stripe key vary. Three variables only, and the fog already thickens on the right side. Test 2 — minimal endpoint. I deploy a Vercel route with one useful line, nodejs runtime explicitly forced, which calls stripe.balance.retrieve() — the most stripped-down SDK call possible, no line_items, no metadata, no idempotencyKey, none of the Checkout's business complexity. In preview: two hundred milliseconds, success. In prod: nine milliseconds, the same StripeConnectionError. Consequence: the problem isn't in the Checkout parameters. It isn't in business logic gone sideways either. The SDK itself crashes on the simplest possible call. Test 3 — bypass the suspect dependency. Instead of calling the SDK, I fetch directly to https://api.stripe.com/v1/balance with the header Authorization: Bearer sk_live_…. In prod, on the same Vercel route: 200 OK, three hundred fourteen milliseconds, payload confirming livemode: true. Consequence — and it's the most precious one — the Vercel→Stripe network infrastructure works. It's strictly the SDK that doesn't cross the network layer. Neither Vercel, nor Cloudflare upstream, nor Stripe downstream are at fault. Niran walks behind my shoulder at that moment, reads the curl output on the terminal. He says three words, "it's not the network", and walks back to his desk without elaborating. Economy of gesture. Test 4 — read the source at the exact error point. The stack trace points to node_modules/stripe/esm/RequestSender.js:400:41. I open the file in the deployed Vercel repo. Line four hundred is the .catch(error) of the internal HTTP client's promise. The SDK was waiting for a response from its own internal client, and its own internal client rejected immediately, before even issuing a request. I climb back into the lib's package.json: "exports": { "worker": { "import": "./esm/stripe.esm.worker.js", "require": "./cjs/stripe.cjs.worker.js" }, "default": { "import": { "default": "./esm/stripe.esm.node.js" } } } Here's what was happening. The stripe^22 package.json declares a conditional "worker" export aimed at Cloudflare Workers environments. The Next 16 bundler, despite export const runtime = 'nodejs' explicitly declared at the top of the route, resolves this "worker" condition when bundling Server Actions in production. The bundle then loads stripe.esm.worker.js, an SDK variant that rests on the Worker runtime's standard fetch and doesn't have the native Node HTTP client. This variant, executed on Vercel's Node runtime, fails silently at the initialisation of its HTTP client — for a reason probably tied to a Cloudflare-specific feature absent from Vercel's runtime — and the promise of the very first request rejects within the next millisecond. The hypothesis isn't a hundred percent confirmed. But it's coherent with the three material facts accumulated: the prod/preview gap that depends on bundle context, the synchronous nine-millisecond failure without network, and the total absence of requestId because no request was ever issued. The workaround written, then the ROI counted In twenty minutes, the diagnostic holds. Forty more minutes, and the helper lib/stripe-fetch.ts is in production on six surfaces — Checkout Sessions, retrieve PaymentIntent, retrieve BalanceTransaction, create off_session PaymentIntent, retrieve Checkout Session, and billing Payment Links. // 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), }) At 5:35 PM, I rerun the funnel in prod with a fake card: the Checkout session opens, livemode confirmed, card plus Link plus Google Pay methods. The 4:13 PM customer receives the apology email and the new link in the next minute. Phase 2 on Monday May 19, sixty-five returning students to chase, unblocked materially. Had I started by patching the timeout, I would have redeployed, waited five minutes, retested, observed the failure, removed the patch, waited five more minutes: a twenty-minute cycle. Add the key rotation — fifteen minutes to generate, propagate, wait for Vercel cache invalidation. And the Stripe support ticket: between two and forty-eight opaque hours, while production bleeds. Compared to these three patches, the protocol holds in under thirty minutes and lands on the true cause — not on a neighbour of the true cause. Generalisation, soberly The protocol holds for any class "same code behaves differently across environments". Trigger symptoms I now keep on top: StripeConnectionError, ECONNREFUSED or ETIMEDOUT at runtime but not at build, Module not found that only appears in prod, or worse — a silent try / catch that returns a misleading fallback and makes you think the main branch succeeded. Four tests, in the same order. Witness, minimal, bypass, source. The protocol does not hold for business bugs — a wrong SQL query, a miscalibrated if, an application logic that returns the wrong result. There the cause is in the code you wrote, and a targeted grep finds it, not a layer discrimination. Coda You don't fix a firing defect by looking at the piece. You look at the kiln's curve, the gas station, the chimney draught. The application code is the piece — it comes out as you shaped it. The four tests interrogate the kiln. Each shuts down a possible lamp until only one remains, which is the right one. Thirty minutes instead of half a day, and above all: the certainty of having patched where it had to be patched, not in a flattering neighbourhood that lets the real bug sleep until the next incident. The 4-test protocol is the applicative instance of the Counterpart Toolkit's R4 Falsify before fix, on the incident class "environment bug". The general rule asks for three probes designed to refute; this class deserves four, in a fixed order. That's all. But that all, the day production bleeds, is worth the half day it saves you. Counterpart Toolkit v0.7, R4 *Falsify before fix. Canonical reference: github.com/michelfaure/doctrine-counterpart. Scenes recomposed, names calibrated on the recurring cast cards of the series.*
2 hours ago
Sardinia beach bans umbrellas for visitors aged 10 to 65
A Sardinian beach in Villasimius, on the island’s south-east coast, introduces rules limiting the use of umbrellas for m...
Royal Marines board Russian “shadow fleet” tanker in English Channel operation
British Royal Marines and specially trained officers board a Russian “shadow fleet” oil tanker, Smyrtos, in the English...
Molly-Mae Hague and Tommy Fury name newborn son Midas, sparking mixed fan reactions
Molly-Mae Hague and Tommy Fury have announced the name of their newborn son, Midas, which is drawing mixed reactions fro...