ITSkillsCenter
Business Digital

Fallback multi-PSP : routage intelligent et résilience pas-à-pas

14 min de lecture

📍 Lecture connexe : Stripe, Paystack, Flutterwave et Wave en 2026 : intégrer un processeur de paiement — pour la vue d’ensemble du paysage paiement.

Aucun processeur de paiement n’a un uptime de 100 %. Stripe, Paystack, Flutterwave et Wave subissent tous des incidents majeurs plusieurs fois par an, qu’il s’agisse de pannes infrastructurelles, d’attaques DDoS, de saturations réseau ou de mises à jour ratées. Pour une boutique qui réalise une part significative de son chiffre d’affaires en ligne, une heure d’indisponibilité d’un PSP peut coûter plus cher que des mois d’effort d’ingénierie. La parade est le routage multi-PSP avec bascule automatique : un PSP primaire en mode normal, un PSP secondaire en mode dégradé si le primaire tombe, et un circuit breaker qui opère la bascule sans intervention humaine. Ce tutoriel implémente le mécanisme complet en TypeScript, avec health checks, score de santé pondéré, et stratégies de retry transparentes pour le client final.

Prérequis

  • Node.js 22 LTS et Redis 7+ pour stocker les états de circuit breaker
  • Au moins deux comptes PSP en mode test (la combinaison la plus didactique est Stripe + Paystack, ou Wave + Flutterwave selon la zone)
  • Lecture préalable des tutoriels d’intégration de chaque PSP que vous souhaitez router
  • Niveau attendu : avancé
  • Temps estimé : environ 110 minutes

Étape 1 — Comprendre les modes de routage

Trois stratégies de routage cohabitent en production. La bascule failover envoie 100 % du trafic au PSP primaire et bascule sur le secondaire uniquement en cas de panne détectée. C’est le mode le plus simple et le plus économique, mais il expose à une période de dégradation pendant la détection de la panne (typiquement 1 à 3 minutes). La répartition active-active répartit le trafic en permanence entre plusieurs PSP selon des critères (pays du client, méthode de paiement préférée, montant). C’est plus complexe mais offre une résilience instantanée. Le routage par préférence propose au client de choisir lui-même son PSP préféré, avec fallback silencieux côté serveur en cas d’échec.

On implémente la bascule failover dans ce tutoriel car c’est le mode le plus polyvalent et qui pose les bases conceptuelles. Une fois maîtrisée, la répartition active-active n’est qu’une variation du même pattern.

Étape 2 — Définir l’interface PSP commune

// src/routing/psp-interface.ts
export interface ChargeOpts {
  amountCents: number
  currency: string
  customerEmail: string
  orderId: string
  idempotencyKey: string
  returnUrl: string
}

export interface ChargeResult {
  pspName: string
  pspTransactionId: string
  paymentUrl: string
  expectedCompletionMs: number // estimation de la latence
}

export interface PSPRouteAdapter {
  name: string
  charge(opts: ChargeOpts): Promise<ChargeResult>
  healthCheck(): Promise<boolean> // ping rapide
}

Cette interface est le contrat que chaque PSP implémente. Le healthCheck retourne un booléen via un endpoint léger (pour Stripe : GET /v1/balance avec timeout court ; pour Paystack : GET /balance ; pour Flutterwave : GET /payouts/fee). Cet appel doit être rapide (sub-1 seconde) pour ne pas devenir lui-même un goulot d’étranglement.

Étape 3 — Implémenter le circuit breaker

Le circuit breaker est un état machine à trois états : closed (normal, on appelle le PSP), open (panne détectée, on saute directement au PSP secondaire), half-open (test de reprise, on tente un appel pour vérifier si le PSP est revenu).

// src/routing/circuit-breaker.ts
import { createClient } from 'redis'

const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()

const FAIL_THRESHOLD = 5         // 5 échecs consécutifs ouvrent le circuit
const COOLDOWN_MS = 60_000       // 1 minute avant de tester la reprise
const HALF_OPEN_TRIALS = 1       // 1 tentative en half-open

export async function getState(psp: string): Promise<'closed' | 'open' | 'half-open'> {
  const state = await redis.get(`cb:${psp}:state`)
  return (state as any) || 'closed'
}

export async function recordSuccess(psp: string) {
  await redis.del(`cb:${psp}:fails`)
  await redis.set(`cb:${psp}:state`, 'closed')
}

export async function recordFailure(psp: string) {
  const fails = await redis.incr(`cb:${psp}:fails`)
  if (fails >= FAIL_THRESHOLD) {
    await redis.set(`cb:${psp}:state`, 'open', { PX: COOLDOWN_MS })
    // Schedule la transition vers half-open
    setTimeout(() => redis.set(`cb:${psp}:state`, 'half-open'), COOLDOWN_MS)
  }
}

Trois choix de design. Le seuil de 5 échecs consécutifs équilibre la sensibilité aux pannes réelles avec la tolérance aux échecs ponctuels (timeout réseau isolé, par exemple). Une cooldown d’une minute donne au PSP le temps de se rétablir s’il s’agit d’un incident transitoire. Et le half-open avec une seule tentative limite le coût de la vérification : si l’appel test échoue, on retombe immédiatement en open pour une nouvelle minute.

Étape 4 — Composer le router

// src/routing/router.ts
import { PSPRouteAdapter, ChargeOpts, ChargeResult } from './psp-interface'
import { getState, recordSuccess, recordFailure } from './circuit-breaker'

export class PSPRouter {
  constructor(private adapters: PSPRouteAdapter[]) {}

  async charge(opts: ChargeOpts): Promise<ChargeResult> {
    const errors: string[] = []
    for (const adapter of this.adapters) {
      const state = await getState(adapter.name)
      if (state === 'open') {
        errors.push(`${adapter.name}: circuit open`)
        continue
      }
      try {
        const result = await adapter.charge(opts)
        await recordSuccess(adapter.name)
        return result
      } catch (e: any) {
        await recordFailure(adapter.name)
        errors.push(`${adapter.name}: ${e.message}`)
        // En half-open, on a déjà fait notre tentative ; on continue le fallback
      }
    }
    throw new Error(`All PSPs failed: ${errors.join(' | ')}`)
  }
}

Le router parcourt les adapters dans l’ordre de priorité (le primaire en premier). Pour chacun, il consulte le circuit breaker : si open, il saute directement au suivant ; sinon, il tente l’appel. En cas de succès, il enregistre la réussite et retourne le résultat. En cas d’échec, il enregistre l’échec et passe au suivant. Si tous échouent, il lève une exception qui remonte au handler HTTP, lequel renvoie une 503 au client avec un message clair.

L’usage côté handler API ressemble à ceci :

import { PSPRouter } from './routing/router'
import { stripeAdapter } from './routing/adapters/stripe'
import { paystackAdapter } from './routing/adapters/paystack'

const router = new PSPRouter([stripeAdapter, paystackAdapter])

app.post('/api/checkout', async (req, res) => {
  try {
    const result = await router.charge({
      amountCents: req.body.amount,
      currency: req.body.currency || 'eur',
      customerEmail: req.body.email,
      orderId: req.body.orderId,
      idempotencyKey: req.headers['idempotency-key'] as string,
      returnUrl: `${process.env.APP_URL}/return`,
    })
    res.json({ paymentUrl: result.paymentUrl, psp: result.pspName })
  } catch (e: any) {
    res.status(503).json({ error: 'payment unavailable, please retry' })
  }
})

Étape 5 — Choisir intelligemment le PSP primaire

Le choix du PSP primaire ne devrait pas être hardcodé : il dépend du contexte de l’utilisateur. Un client français est mieux servi par Stripe (cartes locales et internationales, moins de frais), un client ivoirien par Paystack ou Wave (rails locaux, frais plus bas). On enrichit le router avec une fonction de scoring qui priorise selon le contexte.

function priorityFor(opts: ChargeOpts, country: string): string[] {
  if (country === 'CI' || country === 'SN' || country === 'ML') {
    return ['wave', 'paystack', 'flutterwave', 'stripe']
  }
  if (country === 'NG' || country === 'GH') {
    return ['paystack', 'flutterwave', 'stripe']
  }
  return ['stripe', 'paystack', 'flutterwave']
}

Cette fonction de priorité est appelée à chaque requête et fournit la liste ordonnée des PSP à essayer. Le router parcourt cette liste avec le mécanisme de circuit breaker existant. Le choix du pays vient typiquement de l’IP du client (via un service de géolocalisation comme MaxMind ou ipapi) ou d’un champ explicite envoyé par le front-end.

Étape 6 — Health check actif et passif

Le circuit breaker est passif : il réagit aux échecs réels du trafic. On complète avec un health check actif qui ping périodiquement chaque PSP pour détecter les pannes avant que le trafic ne les rencontre.

// src/routing/health-monitor.ts
import { adapters } from './adapters'
import { recordSuccess, recordFailure } from './circuit-breaker'

const INTERVAL_MS = 30_000

setInterval(async () => {
  for (const adapter of adapters) {
    try {
      const ok = await Promise.race([
        adapter.healthCheck(),
        new Promise<false>(resolve => setTimeout(() => resolve(false), 3000)),
      ])
      if (ok) await recordSuccess(adapter.name)
      else await recordFailure(adapter.name)
    } catch {
      await recordFailure(adapter.name)
    }
  }
}, INTERVAL_MS)

Toutes les 30 secondes, on ping chaque PSP avec un timeout strict de 3 secondes. Si le PSP est lent ou inaccessible, on enregistre un échec qui peut éventuellement ouvrir le circuit avant qu’un client réel ne tombe sur la panne. Cette redondance entre détection passive et active réduit la fenêtre d’exposition à quelques secondes en cas de panne franche.

Étape 7 — Gérer la cohérence des idempotency keys

Un piège du multi-PSP : si le PSP primaire échoue après avoir traité la requête mais avant de retourner la réponse, on bascule sur le secondaire et on risque de double-charger le client. La protection est de générer l’idempotency key avant le routage et de la propager sans modification.

Mieux encore, on segmente la clé : orderId-attempt-N pour chaque tentative côté serveur. Le PSP primaire reçoit order-42-attempt-1, le secondaire (en cas de fallback) reçoit order-42-attempt-2. Cela évite la collision tout en gardant la traçabilité.

Côté base, on enregistre la tentative en cours avant l’appel PSP : si le serveur crash à mi-chemin, le retry au redémarrage saura quel adapter avait été tenté et choisira le suivant.

Étape 8 — Observer la santé du routage

L’observabilité d’un router multi-PSP remonte plusieurs métriques. Le taux de succès par PSP, exposé en série temporelle, permet de visualiser quel PSP est en forme et lequel patine. Le taux de fallback mesure la fraction de requêtes qui ont nécessité plus d’un PSP — un fallback occasionnel est normal, un fallback constant indique que le primaire est mal calibré. Le temps de bascule est la latence ajoutée quand un fallback se produit : doit rester sous 2 secondes pour ne pas être perceptible côté client.

import { Counter, Histogram } from 'prom-client'

const chargeAttempts = new Counter({
  name: 'psp_charge_attempts_total',
  help: 'PSP charge attempts',
  labelNames: ['psp', 'status'],
})
const chargeLatency = new Histogram({
  name: 'psp_charge_duration_seconds',
  help: 'PSP charge duration',
  labelNames: ['psp', 'status'],
})

Un dashboard Grafana minimal affiche ces métriques avec un panneau par PSP et un panneau « santé globale ». Une alerte sur taux de fallback > 20 % sur 5 minutes prévient avant que le PSP primaire ne soit complètement indisponible.

Étape 9 — Tester la résilience

Un système multi-PSP non testé est un système qui ne marche pas. On simule trois scénarios. Premier scénario : couper le réseau vers Stripe (via iptables ou un proxy mock qui retourne 503) et vérifier que le trafic bascule sur Paystack en moins de 30 secondes, sans qu’aucun client ne reçoive d’erreur. Second scénario : Stripe répond mais avec une latence de 30 secondes ; le timeout du circuit breaker doit déclencher la bascule. Troisième scénario : les deux PSP tombent ; le client doit recevoir un 503 propre avec message explicite, pas une exception non gérée.

Ces tests sont automatisés dans la CI avec des PSPs mockés (mock server qui simule chaque comportement) et exécutés à chaque pull request. Le coût d’un mock server local est minime ; le bénéfice de garantir que la résilience reste intacte à chaque release est immense.

Étape 10 — Production hardening

Quelques durcissements supplémentaires pour la mise en production. Limiter le nombre de tentatives de fallback à 3 PSP maximum : au-delà, le client préfère un échec rapide à un appel qui dure 15 secondes. Distribuer la mémoire du circuit breaker sur plusieurs instances : Redis avec TTL gère cela nativement. Logger chaque bascule avec contexte (PSP source, PSP cible, raison, latence) pour les analyses post-mortem. Et configurer une astreinte qui reçoit une alerte SMS quand un PSP reste open plus de 15 minutes — c’est généralement le signe d’un incident sérieux qui demande une intervention humaine.

Étape 11 — Stratégies de fallback avancées

Au-delà de la bascule simple, plusieurs raffinements émergent en production. Le fallback partiel par capability consiste à n’utiliser le PSP secondaire que pour les méthodes de paiement que le primaire ne supporte plus : par exemple si Stripe perd son support 3DS pendant 30 minutes, on bascule uniquement les paiements carte vers Paystack tout en gardant les abonnements existants chez Stripe. Cette granularité demande une matrice de capabilities par PSP, mise à jour en quasi-temps réel.

Le shadow routing envoie une requête au PSP primaire et, en parallèle, une requête identique au secondaire avec un flag de simulation. On ne charge le client qu’une fois (côté primaire), mais on observe la santé des deux PSP simultanément. Cette technique alimente des décisions de bascule plus rapides et permet de comparer les latences réelles sans impact sur l’expérience client.

Le routing pondéré répartit le trafic en pourcentages selon les performances mesurées. Si Stripe répond en 400 ms et Paystack en 700 ms, on envoie 80 % du trafic chez Stripe et 20 % chez Paystack pour garder ce dernier en chauffe. Si Stripe se dégrade à 800 ms, le ratio bascule automatiquement à 50/50, ce qui amorce la bascule complète sans la rendre brutale.

Étape 12 — Gestion des disputes en multi-PSP

Quand un client conteste une charge, la dispute arrive sur le PSP qui a traité la transaction. Sur une plateforme multi-PSP, l’équipe support doit savoir quel PSP a chargé chaque commande. La table de transactions doit donc inclure le champ psp et le psp_transaction_id, indispensables pour répondre à la dispute en consultant le bon tableau de bord et en soumettant les preuves au bon endroit.

L’agrégation des disputes dans une vue unique côté commerçant facilite le traitement. On expose un dashboard interne qui liste toutes les disputes ouvertes, leur PSP, leur montant, leur deadline de réponse, et un lien direct vers le tableau de bord PSP pour soumettre les preuves. Le SLA cible est de répondre à toute dispute en moins de 24 heures, faute de quoi on perd les fonds par défaut.

Étape 13 — Limites du multi-PSP

Le routage multi-PSP n’est pas une panacée. Trois limitations doivent être anticipées. Premièrement, la complexité comptable : chaque PSP a son propre rythme de virement vers le commerçant, ses propres frais, ses propres délais. Réconcilier devient un travail à temps partiel d’un comptable. Deuxièmement, la fragmentation des données client : un client peut avoir une carte tokenisée chez Stripe mais pas chez Paystack ; sa subscription créée chez Paystack ne migre pas automatiquement vers Stripe. Troisièmement, le coût opérationnel : chaque PSP demande sa propre veille technique, ses propres tests d’intégration, son propre runbook d’incident.

Face à ces limitations, le bon réflexe avant de déployer un vrai multi-PSP est de quantifier le coût d’une heure d’indisponibilité versus le coût d’ingénierie et d’opération d’un PSP secondaire (intégration, monitoring, réconciliation, relations contractuelles). Tant que la perte attendue sur une panne reste inférieure au surcoût opérationnel cumulé sur l’année, mieux vaut investir dans la robustesse et l’observabilité d’un seul PSP bien intégré. Dès que les pannes commencent à représenter un manque à gagner significatif sur le compte de résultat, le retour sur investissement d’un second PSP devient rapidement positif.

Erreurs fréquentes

Erreur Cause Solution
Bascule trop sensible (faux positifs) Seuil FAIL_THRESHOLD trop bas (1 ou 2) Régler à 5 échecs consécutifs minimum
Bascule trop lente (clients voient la panne) Pas de health check actif Ajouter un ping périodique toutes les 30 secondes
Double charge après fallback Idempotency key non propagée Générer la clé une fois et la passer à tous les adapters
Circuit qui ne se ferme jamais Pas de transition vers half-open setTimeout sur cooldown qui passe en half-open après expiration
État incohérent entre instances serveur Circuit breaker stocké en mémoire process Stocker dans Redis ou base partagée

Ressources

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité