Le WordPress d'aujourd'hui, décodé pour les développeurs

Performance

Un webhook Stripe qui recalculait deux fois le même panier

Des retries Stripe non idempotents déclenchaient un recalcul complet de commande en double sur une boutique WooCommerce. Retour sur la stabilisation par une file Action Scheduler.

Par WordPress Développement • 14 avril 2021 • 5 min de lecture • Aucun commentaire
Un webhook Stripe qui recalculait deux fois le même panier

« payment_intent.succeeded » : ce même événement Stripe est arrivé trois fois pour une seule et même transaction, à quelques secondes d’intervalle, lors d’un pic de latence réseau entre Stripe et le serveur de la boutique. Rien d’anormal du côté de Stripe : la documentation officielle est explicite sur le sujet, un webhook peut être livré plusieurs fois pour un même événement, notamment si le serveur destinataire met trop de temps à répondre avec un code 200.

Le problème ne venait donc pas de Stripe, mais du traitement WordPress de ce webhook, qui exécutait en direct, dans le corps même de la requête HTTP entrante, l’ensemble de la logique métier : mise à jour du statut de commande, décrément du stock, envoi de l’e-mail de confirmation. Trois livraisons du même événement ont donc déclenché trois exécutions complètes de cette logique.

Ce que révélait l’historique des commandes

L’audit des commandes affectées montrait un stock décrémenté trois fois pour une commande d’un seul exemplaire d’un produit à stock limité, ainsi que trois e-mails de confirmation identiques reçus par le même client à quelques secondes d’écart. Ce comportement n’était pas systématique : il n’apparaissait que lors de pics de latence réseau, ce qui explique pourquoi il n’avait pas été détecté immédiatement en environnement de test, où la latence est généralement stable et faible.

Pourquoi un traitement synchrone est fragile face aux retries

Le gestionnaire de webhook exécutait toute la chaîne de traitement avant de renvoyer une réponse HTTP à Stripe. Si ce traitement prenait plus de temps que le délai d’attente configuré côté Stripe (par défaut quelques secondes), Stripe considère la livraison comme un échec et retente l’envoi, sans savoir que le traitement avait en réalité déjà commencé, voire abouti, côté serveur.

// Traitement synchrone initial, fragile face aux retries
add_action( 'woocommerce_stripe_webhook', function ( $event ) {
    if ( 'payment_intent.succeeded' === $event->type ) {
        $order = wc_get_order( $event->data->object->metadata->order_id );
        $order->payment_complete();
        wc_reduce_stock_levels( $order->get_id() );
        // Toute cette chaîne s'exécute avant la réponse HTTP à Stripe.
    }
});
L'essentiel à retenir : Un webhook Stripe peut légitimement être livré plusieurs fois ; Traiter en direct un webhook expose à des doublons de traitement ; Une file avec verrou d'idempotence absorbe les retries sans risque

La stabilisation : accuser réception immédiatement, traiter en file

La correction retenue consiste à séparer strictement deux responsabilités : accuser réception du webhook auprès de Stripe le plus vite possible, et traiter la logique métier de façon asynchrone via Action Scheduler, la bibliothèque de file de tâches déjà utilisée par WooCommerce en interne. Cette séparation permet de répondre à Stripe en quelques millisecondes, bien avant tout risque de timeout, tout en garantissant qu’une même tâche planifiée ne s’exécute qu’une seule fois grâce à un identifiant unique basé sur l’ID de l’événement Stripe :

add_action( 'woocommerce_stripe_webhook', function ( $event ) {
    if ( 'payment_intent.succeeded' === $event->type ) {
        $lock_key = 'stripe_event_' . $event->id;

        // Verrou d'idempotence : si l'événement a déjà été planifié,
        // on ignore silencieusement les livraisons suivantes.
        if ( false !== get_transient( $lock_key ) ) {
            return;
        }
        set_transient( $lock_key, true, DAY_IN_SECONDS );

        as_enqueue_async_action(
            'traiter_paiement_stripe_confirme',
            [ 'order_id' => $event->data->object->metadata->order_id ],
            'stripe-webhooks'
        );
    }
    // Réponse HTTP 200 renvoyée immédiatement, indépendamment
    // du traitement métier qui suit en tâche de fond.
});

add_action( 'traiter_paiement_stripe_confirme', function ( $order_id ) {
    $order = wc_get_order( $order_id );
    if ( $order && ! $order->is_paid() ) {
        $order->payment_complete();
        wc_reduce_stock_levels( $order->get_id() );
    }
});

Deux garde-fous complémentaires

  • Le verrou d’idempotence basé sur l’identifiant d’événement Stripe empêche qu’un même événement soit planifié deux fois dans la file, même si les livraisons dupliquées arrivent avant l’exécution de la première tâche.
  • La vérification ! $order->is_paid() à l’intérieur de la tâche elle-même constitue un second filet de sécurité, au cas où deux tâches auraient malgré tout été planifiées avant que le verrou ne prenne effet.

Un webhook n’est jamais une garantie de livraison unique. Le traiter comme tel, sans verrou d’idempotence, revient à parier sur la stabilité du réseau entre deux services que l’on ne contrôle ni l’un ni l’autre.

En résumé

Les retries de webhooks Stripe sont un comportement documenté et attendu, pas un dysfonctionnement. La fragilité venait du traitement synchrone en direct, sans protection contre les livraisons multiples. En déportant la logique métier vers une file Action Scheduler protégée par un verrou d’idempotence basé sur l’identifiant d’événement, les recalculs redondants de commande ont totalement disparu, sans qu’il ait fallu toucher à la configuration Stripe elle-même.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Partager :

À propos de l'auteur

WordPress Développement

Développeur WordPress, passionné par Elementor, le FSE et l’automatisation par IA.

Voir tous ses articles

Dans la même veine

À lire aussi