~10 min de lecture
Pourquoi mes webhooks ne renvoient plus de 5xx
Mardi, 15h - vous vous apprêtez à effectuer un petit déploiement.
Tout a l'air de bien se passer, parfait ; mais 2 heures plus tard, un souci apparaît !
Votre endpoint /bank/card/webhook lève, sous certaines conditions, une exception ! À cause d'une des nouvelles règles métier !
Non seulement c'est embêtant, mais en plus le snowball effect est instantané :
- un empilement d'erreurs (et oui... le client/émetteur - du endpoint/du webhook - retry, avec du délai, à chaque 500)
- un traitement partiellement effectué - des cartes sont générées chez le client/émetteur, puis chez vous, mais le traitement n'arrive pas au bout de la chaîne et vos utilisateurs finaux ne les reçoivent pas... en fin de compte vous allez devoir faire un dev de réparation sur mesure JUSTE pour de la donnée... sans compter le support !
- des clients/émetteurs qui ne vous contactent plus pendant un temps (circuit-breaker open)
... Bref, vous allez passer une mauvaise semaine, et vous risquez certainement de ne pas être en mesure de fix parfaitement une partie des opérations précédemment jouées.
Ce cas, vous l'avez probablement déjà rencontré, et quelle que soit la cause de votre erreur (typo, timeout sur un tiers, problème de cache, dépendance manquante...), la solution à mettre en place sera identique.
Le problème principal est le suivant: votre endpoint fait un ack métier, il devrait faire un ack technique.
Et pour le résoudre, on va pouvoir se reposer sur plusieurs patterns !
À noter que pour la suite de cet article, je vais constamment me référer à mon projet démo "Webhook Ledger". Un repository contenant le code est également disponible, contenant un README.md qui revient sur certaines approches et certains choix techniques.
Projet de démo lié
Webhook Ledger - Réception & journalisation de webhooks, avec retry automatique des échecs.
Introduction : mise en place du endpoint de consommation de webhook
Le but de notre ledger est d'automatiser et sécuriser la réception de webhook, et de lister ces derniers. Le plan que j'ai mis en place est le suivant (avec entre parenthèse les thèmes techniques abordés dans chaque phase):
- réception et validation - (idempotency)
- persistance, dispatch & réponse - (atomicity, dual-write, transactional outbox)
- traitement asynchrone - (at-least-once, pessimistic locking)
Réception & validation
La réception du message se fait via une route de controller (Symfony). Toute la question va être de déterminer ce dont ce dernier est en charge, et ce qu'il ne doit surtout pas faire.
J'en profite pour insister sur le fait que notre webhook est mis à disposition de clients. Il s'agit de Stripe et Github dans ce projet, mais ça pourrait également être des services internes dans une architecture distribuée. Notre stack / infra peut être faillible à un instant T, tout comme celle de nos clients. Ainsi, il est important de se prémunir contre des appels identiques répétés, que ce soit à cause d'un retry (suite à une 5xx de notre part) ou une défaillance du client (sait-on jamais).
La note ci-dessus établit que nous avons besoin d'un comportement idempotent: avoir un acteur tiers effectuant des modifications dans notre système peut toujours mal tourner. Aussi, l'idempotence permet de se prémunir d'effets doublons. Sur notre projet démo, cela n'a pas trop d'incidence, mais sur une prod, cela signifierait démultiplier les exécutions métier (ex: plusieurs envois du même e-mail pour le même utilisateur). Bref, on préfère éviter.
Pour ce faire, nous devons déterminer ce qui définit l'unicité d'un webhook. Les Stripe, Github et autres consorts fournissent toujours un id d'évènement, sur lequel nous allons nous baser (ainsi qu'un autre attribut ... mais ne précipitons pas les choses !).
Dorénavant, lorsque je parlerai d'évènement de webhook, j'utiliserai le terme "webhook_event" qui est le nom de table que j'ai choisi.
Ensuite, l'appel étant fait par un tiers, il faut s'assurer de deux points: que l'émetteur soit bien le bon, et que le payload soit interprétable de notre côté (le fameux id d'évènement dont je viens de faire mention).
Voici une petite représentation de ce que devrait faire notre controller:
- vérifier la validité de la signature (via
hash_equals) - (SI -> signature non valide) on enregistre (via une classe de service dédiée) mais on ne publie pas, on répond par une 401
- (SINON) validation du format
- (SI -> format non valide) on répond par une 422
- on enregistre & on dispatch (via classe dédiée)
- on répond par une 202
J'ai décidé d'enregistrer un webhook_event malgré une signature invalide en pensant que ça pourrait être une donnée intéressante. Ce n'est pas une obligation, il s'agit encore une fois d'un projet de démo et ce type de décision doit être prise en fonction de votre business.
Persistance, dispatch & response
Cette section risque d'être assez longue, alors attachez vos ceintures. Maintenant que notre controller existe, et que nous avons décidé de partir sur de l'idempotence, il faut construire un système qui permette de garantir cette dernière.
Plus tôt, j'ai abordé le fait de définir ce qui rendait notre webhook_event unique. Afin de matérialiser cette unicité, j'ai décidé de mettre en place une contrainte SQL via l'attribut Doctrine #[ORM\UniqueConstraint].
Cette dernière est assurée par la combinaison external_event_id + source. Dans mon repository, lors de la création de mes webhook_event, je catch les éventuelles UniqueConstraintViolationException afin de les encapsuler dans une exception maison, que je re-throw. Je peux ainsi garantir mon idempotence via un ultime catch du controller, tout en évitant un couplage direct à Doctrine.
J'ai mon unicité, ma persistance, mon idempotence ... je peux maintenant passer au comportement asynchrone de mon app, à savoir: traiter le métier relatif au webhook - enfin !
Malheureusement, ce n'est pas aussi simple... nous allons nous attaquer à un nouveau problème: le Dual-Write !
Le dual-write, pour faire simple, est un cas qui se pose lorsque vous avez besoin de cohérence entre deux systèmes de gestion de la donnée. Ici, d'un côté il y a la base de données, et de l'autre votre transport AMQP... et rien ne garantit, si votre donnée est bien sauvegardée, que votre message ne soit pas perdu !
Si cela arrivait, vous perdriez le traitement métier lié à cette donnée. Un cas concret: vous avez une carte bancaire émise et enregistrée en base, mais le mail n'a pas été envoyé au client !
Ce que ça implique dans notre cas: Même si notre webhook_event est dorénavant enregistré en base de données, il existe un risque que le traitement métier post-enregistrement ne soit pas exécuté.
Et pour résoudre ce problème, on va passer par le Transactional Outbox - un pattern adapté à notre situation.
L'idée de base du pattern Transactional Outbox est d'écrire en base à la fois notre donnée, mais aussi l'intention de la publier, et ce de manière atomique (même transaction). Ensuite, un relais s'occupe de publier le message. On ne peut alors plus se retrouver avec une donnée enregistrée dont le traitement ne partira jamais, ni l'inverse.
Afin de rendre ce pattern opérationnel, il faut donc s'assurer de l'atomicité des insertions en base de données.
Le composant messenger de Symfony permet, lorsque l'on utilise Doctrine, de stocker les messages en attente de traitement dans une table messenger_messages. Et on peut utiliser Doctrine en tant que dsn ! Ainsi, plutôt que de refaire une table outbox complète avec son propre relais, j'utilise directement le mécanisme de messenger :).

Plusieurs choses à noter ici: la valeur du dsn async, l'auto_setup, la retry_strategy ... on vient d'aborder le dsn.
L'auto_setup=0 permet d'éviter la création de la table lors du premier dispatch (comportement par défaut du package). Or, en MySQL, toute instruction DDL provoque un commit implicite ! On se retrouverait - lors du premier dispatch - avec deux transactions, et une perte de l'atomicité. Le risque n'est pas énorme, mais autant l'éviter: il faudra donc s'assurer qu'une migration créant la table soit générée.
Enfin, la stratégie de retry permet d'avoir un rejeu en cas d'échec, toujours utile, surtout sur des systèmes distribués. J'en parle plus en détail un peu plus loin dans cet article.
On branche ensuite un listener sur les events de sorte à changer le statut de notre webhook_event afin de signifier qu'il a bien été dispatch.
WorkerMessageReceivedEvent→ "dispatched"
La dernière étape est de s'assurer que les enregistrements soient bien effectués au sein d'une transaction atomique.

Attention, cela fonctionne parce qu'ici nous passons par la même connexion DBAL que pour notre insertion. Si vous utilisez deux connexions différentes, vous vous retrouverez avec deux transactions et ça ne fonctionnera plus ; et là, le dual-write réapparaîtra.
Et voilà ! Grâce à l'utilisation de Doctrine en tant que dsn, et à notre listener qui écoute ces 3 évènements Symfony, on obtient un début de Transactional Outbox très simple à mettre en place ! Plus que quelques branchements et elle sera complètement fonctionnelle !
À partir de là, tout le reste est traité en différé (asynchrone) car pris en charge par les workers de Symfony. Une fois le wrap terminé, la transaction est COMMIT (ou ROLLBACK en cas d'erreur) et notre controller reprend la main afin de choisir le type de réponse à retourner: 202, 401 ou 422.
Traitement asynchrone
La partie synchrone étant terminée, on va se pencher sur le reste. Ce qu'il nous manque, à cette étape, ce sont principalement deux tâches:
- la mise à jour du statut de notre
webhook_event - le branchement de notre métier (qui n'est pas sans importance puisque sans lui notre réception de webhook ne sert pas à grand chose finalement)
Heureusement, on va très rapidement et simplement parvenir à nos fins. En effet, il n'y a quasiment qu'à enrichir notre listener afin qu'il écoute deux évènements Symfony supplémentaires. Au final, nous nous retrouvons avec ce mapping:
WorkerMessageReceivedEvent→ "dispatched"WorkerMessageHandledEvent→ "succeeded"WorkerMessageFailedEvent→ "failed" (ou "dead" après épuisement des retries)
C'est très bien tout ça, mais il nous manque le branchement au métier !
Facile: on met en place un handler qui sera en charge de faire la transition worker / métier. Le WorkerMessageHandledEvent sera ensuite dispatché automatiquement une fois que votre code aura été exécuté avec succès. En cas d'échec, ça part dans le retry.
Et la stratégie de retry justement, on devait en reparler ?
Simple et efficace: 5 essais, un délai de 1000ms qui double entre chaque tentative, et du jitter pour éviter une potentielle saturation des workers.
Ah oui, j'oubliais presque: le worker Symfony utilise nativement du pessimistic locking (un SELECT ... FOR UPDATE), aucun risque donc, qu'un même message soit pris en charge par plusieurs workers en simultané.
Par contre, il reste un risque: si un worker meurt entre l'exécution de votre handler et la suppression de la ligne, le message repartira. C'est le fonctionnement de l'at-least-once: la livraison est garantie au moins une fois.
En conséquence, et pour garantir une fiabilité totale, votre handler devra lui aussi adopter un comportement idempotent.
Conclusion
Notre endpoint n'a maintenant plus de raison de renvoyer une 5xx à cause de notre métier ! Seuls des problèmes techniques (par exemple, une DB injoignable) en occasionneront, et c'est exactement ce que l'on cherchait à faire.
Comme dit précédemment, le code est sur le dépôt, et l'instance est en ligne. Amusez-vous à POST deux fois le même external_event_id, vous verrez bien ce qui se passe ;)
Projet de démo lié
Webhook Ledger - Réception & journalisation de webhooks, avec retry automatique des échecs.