Un paiement est lancé depuis votre backend. Vous ouvrez une transaction, mettez à jour la commande, appelez le prestataire avec await, puis validez la transaction si tout se passe bien. Sur le papier, c'est logique : en cas d'échec, vous annulez le reste. En développement, avec un service tiers rapide et peu de charge, ça passe souvent sans problème visible.
Le souci arrive quand le réseau ralentit. Pendant que votre code attend la réponse HTTP, la transaction reste ouverte et la connexion à la base reste généralement retenue. Si plusieurs requêtes font exactement la même chose en même temps, le pool de connexions se remplit. Et à partir de là, des opérations qui n'ont rien à voir avec le prestataire peuvent attendre une connexion... ou échouer à leur tour.
Le await n'est pas le problème en soi. Le problème, c'est de garder une connexion SQL « réservée » pendant une opération réseau dont vous ne maîtrisez ni la durée ni la disponibilité. Voici comment repérer ce couplage, refaire le flux proprement et gérer les échecs sans faire croire qu'une transaction SQL peut rendre atomiques deux systèmes indépendants.
Pourquoi un appel externe bloque-t-il le pool de connexions ?
Un pool met à disposition un nombre limité de connexions à la base. Une requête emprunte une connexion, exécute son travail, puis la rend au pool. Une transaction, en revanche, doit en général conserver la même connexion entre BEGIN et COMMIT (ou ROLLBACK).
Si, entre ces deux étapes, votre code attend une API externe, la connexion ne revient pas au pool pendant toute la durée d'attente. La base n'a aucune idée de ce que vous faites côté application. Elle voit surtout une session dont la transaction est encore active, parfois avec des verrous conservés.
La latence finit par saturer tout le monde
Imaginons un pool de 20 connexions. Tant qu'une transaction dure quelques millisecondes, ces connexions enchaînent les requêtes sans trop de difficulté. Mais si un appel HTTP dure quatre secondes, chaque transaction correspondante garde une connexion immobilisée pendant ces quatre secondes, même si la base ne « travaille » plus.
On peut raisonner simplement : le nombre de requêtes simultanées en attente ressemble à (débit) × (durée d'attente). À dix appels par seconde et quatre secondes d'attente, on est vite autour de quarante opérations « en cours ». Si chacune garde une connexion pendant l'attente, un pool de 20 ne tient déjà pas la route, même avant de compter les autres usages de la base.
Le scénario typique suit une progression assez classique :
- Le service tiers ralentit (ou est partiellement en panne).
- Les transactions restent ouvertes plus longtemps.
- Les connexions disponibles se raréfient, puis disparaissent du pool.
- Les nouvelles requêtes attendent une connexion, ou dépassent le délai d'acquisition.
- Des lectures et contrôles « internes » sont aussi impactés, même s'ils n'ont rien à voir avec l'API lente.
Au final, c'est une dépendance externe qui finit par dégrader un composant partagé (le pool), puis des morceaux de l'application qui, eux, n'étaient pas censés être liés à ce prestataire.
await n'est pas la faute : c'est la ressource retenue
En Node.js, await laisse le processus traiter d'autres tâches. Il ne « bloque » pas le thread comme un appel synchrone classique. Mais il ne rend pas automatiquement les ressources détenues autour : si le callback de transaction n'est pas terminé, la connexion associée reste occupée.
Du coup, il faut distinguer deux choses :
- Est-ce que votre application peut traiter d'autres événements pendant l'attente ?
- Est-ce que la connexion SQL réservée par cette requête est disponible pour les autres ?
La première réponse peut être « oui », la seconde « non ». Et c'est exactement comme ça qu'on se retrouve avec une appli « réactive » au niveau du processus, mais incapable d'obtenir une connexion.
Pourquoi une transaction SQL ne garantit-elle pas la cohérence avec une API ?
Une transaction de base de données apporte des garanties ACID à l'intérieur d'un même moteur : atomicité, cohérence, isolation, durabilité. Elle peut annuler une mise à jour locale si une autre requête SQL échoue.
Mais elle ne peut pas annuler automatiquement un paiement accepté côté prestataire. Le prestataire a ses propres états, ses propres délais, ses propres règles de reprise. La transaction SQL ne coordonne que votre base.
Le cas où le délai « casse » votre perception
Imaginez : le prestataire reçoit une demande, traite le paiement, puis votre réponse se perd (réseau, proxy, coupure). Côté application, l'appel HTTP finit par expirer.
Résultat : votre code voit un échec et déclenche un ROLLBACK local (ou ne marque pas la commande comme payée). Mais côté prestataire, le paiement a probablement eu lieu.
Si vous relancez « directement » ensuite, sans protection, vous risquez de facturer deux fois. Et surtout : garder une transaction ouverte pendant l'appel ne supprime pas le scénario. Le réseau peut tomber après le traitement distant, et un ROLLBACK local ne fera pas disparaître ce qui a été produit ailleurs.
Remplacer l'atomicité impossible par des états explicites
Quand plusieurs systèmes sont impliqués, le but réaliste n'est pas toujours « tout réussit ou rien ne change ». C'est souvent plutôt : enregistrer l'intention, savoir où on en est, puis converger vers un état final malgré les erreurs et les redémarrages.
Concrètement, une commande peut passer par des états comme pending, processing, completed ou failed. Mais ça ne sert à quelque chose que si vous définissez les transitions : qui change l'état, dans quelles conditions, et comment traiter une opération bloquée.
Un statut pending tout seul ne « résout » rien : il rend seulement le problème plus visible dans la base.
La frontière est donc simple : utilisez une transaction pour garantir la cohérence de vos écritures locales. Pour synchroniser ça avec une API, construisez un workflow distribué : états, reprises idempotentes, et éventuellement une action compensatoire.
Comment découpler les écritures et l'appel API ?
Le premier réflexe : terminer la transaction avant de contacter le service externe. Vous enregistrez l'intention rapidement dans la base. Ensuite, vous faites l'appel réseau sans connexion SQL immobilisée. Enfin, vous enregistrez le résultat.
await db.transaction(async (trx) => {
await trx('orders')
.where({ id: orderId })
.update({ status: 'pending_external_call' });
});
let response;
try {
response = await externalApi.sendRequest({ orderId });
} catch (error) {
await db('orders')
.where({ id: orderId })
.update({ status: 'failed', last_error: error.message });
throw error;
}
await db.transaction(async (trx) => {
await trx('orders')
.where({ id: orderId })
.update({
status: 'completed',
external_reference: response.id,
});
});
L'idée est simple : les transactions restent courtes, et une latence de l'API n'immobilise pas une connexion SQL « pour rien ».
Quand un flux synchrone suffit
Pour une fonctionnalité pas trop critique, avec un volume maîtrisé et un besoin de réponse immédiate à l'utilisateur, ce découpage peut suffire : la base enregistre pending_external_call, puis vous appelez l'API hors transaction, puis vous mettez à jour l'état.
Dans ce cas, il faut décider quoi renvoyer si le prestataire ne répond pas : une erreur temporaire, un état « en cours » (et un mécanisme de suivi), ou une demande de reprise côté client.
Et gardez en tête les fenêtres de panne : le processus peut s'arrêter juste après avoir écrit pending_external_call mais avant d'appeler l'API. Ou juste après l'envoi mais avant d'enregistrer la réponse. Un flux synchrone n'efface pas ces scénarios : il doit s'appuyer sur une récupération (reprise) si les pertes ne sont pas acceptables.
Quand passer à l'outbox (boîte d'envoi)
Pour un flux critique, le pattern outbox ajoute un filet solide. Dans une même transaction locale, vous mettez à jour la commande et vous créez un événement dans une table d'outbox. Un worker lit les événements non traités, appelle le service externe hors transaction, puis marque l'événement comme traité.
L'intérêt est concret : la modification métier et l'intention d'envoyer l'événement sont validées ensemble. Si le processus meurt juste après le commit, l'événement reste en base et le worker peut le reprendre.
Attention : l'outbox ne rend pas l'appel externe atomique, et elle ne garantit pas « exactement une fois » à elle seule. Le worker peut réessayer après une coupure. Pour éviter les effets en double, il faut combiner l'outbox avec une clé d'idempotence côté prestataire, une politique de reprise, et de la surveillance (événements trop anciens, blocages, etc.). Oui, c'est un peu plus de plomberie. Mais quand le coût d'un doublon ou d'une perte est réel, ça vaut le coup.
Comment gérer les échecs, les reprises et les doublons ?
Une fois l'appel hors transaction, la cohérence devient une responsabilité du workflow. Ce n'est pas un « défaut » du découpage : c'est le fonctionnement normal d'une interaction entre systèmes qui peuvent tomber en panne indépendamment.
Rejeter ce qui est rejeté, réconcilier ce qui est incertain
Tous les échecs ne se valent pas.
- Une réponse explicite de refus : c'est un rejet « clair », vous pouvez passer à
failed. - Un timeout, une connexion coupée, une erreur serveur : c'est un résultat indéterminé. Le prestataire a peut-être traité la demande.
Cette distinction doit guider la transition d'état. Pour un rejet définitif : failed. Pour un résultat incertain : plutôt un état récupérable comme pending_reconciliation, en attendant une vérification (consulter le prestataire, ou relancer avec une stratégie sûre).
Idempotence et reprises bornées
Si le prestataire supporte l'idempotence, envoyez une clé stable pour une même opération (identifiant de commande ou de paiement). Une nouvelle tentative avec la même clé doit retrouver le résultat précédent plutôt que créer une seconde opération.
Vérifiez au passage les détails du contrat : durée de conservation de la clé, périmètre d'unicité, comportement si d'autres paramètres changent.
Côté reprises, gardez-les bornées : tentatives limitées, délai progressif, et arrêt sur erreurs permanentes. Après un certain nombre d'échecs, vous basculez sur un mécanisme de file/alerte ou un état qui nécessite une intervention. Un compteur sans seuil ni propriétaire ne sert à rien : au mieux, il vous donne un beau « toujours en échec » que personne n'attrape.
Réconcilier ce qui reste coincé
Un worker de réconciliation recherche les opérations trop longtemps dans un état intermédiaire. Le seuil dépend du délai normal côté prestataire et de vos engagements métier : « quelques minutes » peut convenir pour un paiement dans votre contexte, mais la valeur doit venir des données, pas d'une intuition.
Ensuite, pour chaque opération, vous :
- Consultez l'état distant.
- Relancez la demande avec la même clé si nécessaire.
- Marquez l'opération comme terminée ou « à examiner ».
- Déclenchez une action compensatoire si besoin (remboursement, annulation, libération de réservation).
Une action compensatoire ne « réécrit pas l'histoire ». Elle crée une nouvelle opération qui corrige l'impact métier.
Quels garde-fous configurer en production ?
Le bon découpage réduit beaucoup le risque, mais il ne suffit pas. En production, vous voulez limiter les cas où une requête monopolise des ressources, et transformer les pannes en erreurs exploitables plutôt qu'en blocages.
Définir des délais cohérents
Configurez un timeout HTTP pour chaque appel, y compris la réception des en-têtes et du corps si votre client le distingue. Le timeout doit correspondre à ce que vous pouvez tolérer, sans dépasser le délai global de la requête.
- Trop long : vous masquez les pannes et vous accumulez l'attente.
- Trop court : vous créez des reprises inutiles.
Côté base, mettez aussi des garde-fous. Dans PostgreSQL :
statement_timeoutlimite la durée d'exécution d'une instruction.idle_in_transaction_session_timeoutpeut tuer une session inactive pendant une transaction.
Ces paramètres sont des filets de sécurité. Ils ne justifient pas de placer un appel réseau dans une transaction.
Enfin, fixez le délai d'acquisition du pool : si aucune connexion n'est disponible, l'application doit échouer proprement plutôt que d'attendre « pour toujours ». Un pool plus grand peut absorber une pointe, mais il ne règle pas le fait qu'une transaction garde une connexion pendant plusieurs secondes. Et si vous augmentez sans mesurer, vous déplacez la saturation vers la base.
Surveiller ce qui révèle la panne
Au minimum, suivez :
- Le nombre de connexions utilisées et disponibles.
- Le temps d'attente pour obtenir une connexion.
- La durée des transactions.
- Le nombre de sessions « inactives en transaction ».
Puis corrélez ça avec :
- La latence et le taux d'erreur du service externe.
Enfin, testez pas seulement le débit. Faites aussi des tests avec une API lente, des timeouts, et une indisponibilité temporaire. Vérifiez que les lectures hors intégration restent disponibles, que les reprises ne dupliquent pas, et que les états intermédiaires se résolvent bien.
Checklist de revue
- Aucun appel HTTP n'est exécuté entre l'ouverture et la fermeture d'une transaction SQL.
- Les transactions ne contiennent que des opérations locales nécessaires à leur cohérence.
- Les délais HTTP, SQL et d'acquisition du pool sont définis.
- Les erreurs distinguent un rejet explicite d'un résultat distant incertain.
- Les opérations rejouables utilisent une clé d'idempotence quand le fournisseur le permet.
- Les états intermédiaires ont un mécanisme de reprise et une alerte.
Conclusion
Une transaction SQL ne devrait pas attendre un serveur tiers. Cette attente immobilise une connexion, expose vos ressources à une latence que vous ne contrôlez pas, et peut impacter des fonctionnalités qui n'ont rien à voir avec l'intégration.
Gardez les transactions pour les écritures locales qui doivent être cohérentes ensemble. Faites l'appel réseau hors transaction, rendez les états intermédiaires explicites, et prévoyez des délais, des reprises idempotentes et une réconciliation. Pour un flux critique, l'outbox aide à fiabiliser l'intention d'envoi sans prétendre rendre deux systèmes atomiques.
Avant d'augmenter votre pool, regardez la durée réelle des transactions et simulez une dégradation du service tiers. Si une panne externe peut bloquer toute l'application via le pool, le problème vient de la frontière du workflow, pas de la taille du pool.
