Webhooks at scale @Yousign

Fabien Paitry
Yousign Engineering & Product
8 min readFeb 13, 2024

Yousign s’appuie sur des webhooks pour notifier ses clients lorsqu’un événement se produit (par exemple, lorsqu’un document est signé). La croissance rapide de Yousign nous amène à envoyer de plus en plus de webhooks chaque jour et nous avons récemment été confrontés à des problèmes de scalabilité nous ayant conduit à une refonte majeure de l’architecture de nos webhooks.

Photo by Steve Johnson on Unsplash

Webhook ?

Un webhook est une fonction de rappel utilisée pour la communication événementielle entre deux systèmes basée sur le protocole HTTP.

Un bon cas d’utilisation des webhooks est le processus de signature électronique des documents, qui est par nature asynchrone.

En moyenne, quelques heures s’écoulent entre l’envoi d’un document pour signature et la signature effective par le destinataire (le signataire).
Dans ce scénario, l’expéditeur du document (par exemple un client API) dispose de deux moyens pour vérifier l’état du document :

  • interroger une route HTTP jusqu’à ce que le statut du document ai changé (long polling), ce qui n’est pas une solution viable car la plupart des APIs implémentent des politiques de rate limiting.
  • exposer un point d’accès HTTP qui peut être appelé par le fournisseur de signature électronique du document (par exemple l’API Yousign) à la fin du processus de signature. C’est basiquement ce qu’est un webhook et c’est la solution que nous recommandons pour intégrer notre API.

Bien que le principe des webhooks soit relativement trivial, il est important de prendre en considération qu’un webhook est potentiellement un vecteur d’attaque et qu’il n’existe aucune garantie que le serveur destinataire soit en mesure de traiter le webhook à un instant T.

Un webhook nécessitant d’exposer un endpoint (sur Internet ou non), il est indispensable de s’assurer que le serveur à l’origine de la requête soit connu. Soit en vérifiant son IP soit idéalement par le biais d’une signature HMAC qui permet de contrôler que le payload du webhook ait bien été signé par l’émetteur du webhook.

L’API Yousign permet de mettre en place ces deux types de validation et intègre également un identifiant unique et un timestamp dans le payload des webhooks afin de se prémunir des attaques de type replay qui consistent à ré-émettre plusieurs fois le même webhook.

L’API Yousign met également en place un mécanisme de retry permettant de garantir que nos clients reçoivent nos webhooks même s’ils rencontrent des problèmes de stabilité sur leur infrastructure. L’API Yousign essaye d’appeler le endpoint client défaillant jusqu’à 8 fois en utilisant un mécanisme de back-off (temps d’attente entre chaque retry) toutes les 2min, 6min, 30min, 1h, 5h, 18h, 24het 48h.

Un dernier point dont il faut tenir compte c’est qu’un endpoint peut avoir un temps de réponse variable allant de quelques millisecondes jusqu’à plusieurs secondes. Il faut donc adapter les timeouts en conséquence.

Webhook et scalabilité

L’API Yousign V3 envoie environ 120 000 webhooks par jour. Les temps de réponse des endpoints de nos clients sont hétérogènes de quelques millisecondes à plusieurs secondes et nous avons par conséquent configuré le timeout de notre client HTTP à 10 secondes ce qui est suffisant pour l’ensemble de nos clients.

Les webhooks sont gérés de manières asynchrone via une queue RabbitMQ et sont traités par des workers Symfony que nous pouvons upscaler si nous détectons de la congestion dans la queue RabbitMQ.

Certains de nos clients ont recours à des traitements par batch pour envoyer des demandes de signatures, ce qui génère un envoi en masse de webhooks. C’est un cas d’utilisation tout à fait acceptable, cependant il peut arriver que le serveur appelé par le webhook soit indisponible. Dans ce cas nous allons attendre pour chaque itération le timeout de 10 secondes avant de considérer que le webhook ne répondra pas et le basculer dans le mécanisme de retry.

Le mécanisme de retry utilisant la même queue RabbitMQ on peut très rapidement stacker les messages et générer de la latence pour l’ensemble de nos clients.

Concrètement, si un client créé 1000 demandes de signatures en batch et a configuré un webhook pointant vers le endpoint https://my-server/webhook l’API Yousign va générer 1000 messages RabbitMQ et tenter d’appeler 1000 fois le endpoint.
Si ce endpoint ne répond pas, le temps de traitement cumulés atteint les 10000 secondes. Ces messages seront alors retry avec les mêmes conséquences in fine si le endpoint ne répond toujours pas.

Lorsque cela arrive, nous sommes obligé d’upscaler nos workers pour consommer les messages et éviter la saturation de la queue. Si plusieurs de nos clients subissent en simultané une indisponibilité, nous prenons le risque de ne pas pouvoir dépiler les messages dans un temps raisonnable.

Fail fast policy

La première action prise a été la mise en place d’une politique de fail-fast en limitant le timeout à 1s lors du premier appel au webhook ce qui correspond au 97 percentiles des temps de réponse moyen des endpoints:

Repartition des temps de réponses
Répartition des temps de réponses

Lors du premier appel le endpoint doit répondre en moins de 1s, le cas échéant il sera retry avec un timeout de 10s dans une queue RabbitMQ dédiée au retry.

Ce mécanisme permet de conserver un débit important sur la queue principale tout en conservant un fall back pour les endpoints les moins véloces. Cela permet également de scaler les workers indépendamment sur chaque queue si nécessaire.

Circuit breaker

Dans l’absolu, on peut considérer que si un endpoint client ne répond pas après plusieurs tentatives successives c’est que le serveur destinataire subit une interruption de service. Dans ce cas de figure il est inutile de tenter d’appeler le serveur et il est préférable d’attendre que le serveur destinataire puisse traiter la requête.

C’est ici qu’intervient le pattern circuit breaker. Le circuit breaker permet dans notre cas de stopper l’envoi des webhooks temporairement si le serveur distant ne répond pas (connection refused, timeout, …), dans ce cas le circuit est dit ouvert (open).

Passé une période prédéfinie, le circuit breaker va retenter d’appeler le serveur en laissant passer 1 requête HTTP, c’est la position semi-ouverte (half-open).

Si le serveur répond de nouveau, on laisse passer l’ensemble du trafic, c’est la position fermée (closed).

Dans notre cas, le circuit breaker est scopé par endpoint en excluant la query string. Par exemple, pour ce endpoint https://mycompany.tld/mywebhook?id=5 la clé d’unicité du circuit breaker sera https-mycompany-tld-mywebhook.

Pour plus d’informations sur le pattern circuit breaker: https://martinfowler.com/bliki/CircuitBreaker.html

Ganesha

Dans notre cas nous utilisons une implementation PHP nommée Ganesha. Cette bibliothèque fourni (entre autre) une intégration avec le HttpClient de Symfony.

Ganesha peut être configurée pour suivre deux stratégies distinctes afin de déterminer si le circuit doit être ouvert ou non :

  • la stratégie Rate: le circuit est ouvert lorsque le ratio entre les appels HTTP non réussis et les appels HTTP réussis est supérieur au seuil défini pendant la fenêtre de temps définie.
  • la stratégie Count : le circuit est ouvert tant que le compteur est supérieur à un seuil défini. Dans ce mode, chaque appel HTTP infructueux et réussi augmente et diminue respectivement le compteur.

Dans notre contexte, la stratégie Rate est la plus adaptée car le volume d’appels sur des webhooks varie beaucoup en fonction des clients (de quelques appels par jour jusqu’a plusieurs dizaines de milliers).

Cette stratégie a besoin de 4 paramètres pour être configurée :

  • timeWindow : l’intervalle de temps (secondes) durant lequel sont évalués les appels HTTP. Passer ce délai les compteurs sont réinitialisés
  • failureRateThreshold : le seuil du taux de défaillance en pourcentage qui change l’état du disjoncteur à OPEN
  • minimumRequests : le nombre minimum de requêtes pour détecter les défaillances. Même si failureRateThreshold dépasse le seuil, le disjoncteur reste dans l’état CLOSED si le nombre de requêtes HTTP est inférieur à ce seuil
  • intervalToHalfOpen : l’intervalle (en secondes) pour changer l’état du circuit de OPEN à HALF_OPEN

A titre d’exemple vous trouverez ci dessous la configuration Ganesha que nous utilisons actuellement :

time_window: 600
failure_rate_threshold: 40
minimum_requests: 20
interval_half_open: 300

State management

Ganesha a besoin d’un stockage pour pouvoir garder une trace des succès et des échecs et ainsi déterminer l’état du circuit. Dans notre cas nous devons utiliser un stockage distribué car nous avons plusieurs workers qui traitent les webhooks et il est donc nécessaire d’avoir une source unique de vérité.

Nous avons choisis Redis pour deux raisons principales:

  • nous l’utilisons déjà comme moteur de cache
  • c’est l’un des deux stockages supporté par Ganesha qui nous permet d’utiliser la stratégie Rate avec l’implémentation SlidingTimeWindow.

Ganesha gardera le circuit fermé si il n’arrive pas à joindre Redis. Ce qui revient in fine à ne pas avoir de circuit breaker.

Benchmarks

Nous avons conduit une série de tests de performance afin de valider notre implémentation.

Les tests simulent la création en masse de demandes de signature.
Afin de valider la fail fast policy et le circuit breaker nous avons créé 3 webhooks:

  • 1 endpoint répondant en moins de 500 ms: succès dans la queue principale
  • 1 endpoint répondant en 2s: tombe en timeout dans la queue principale puis succès dans la queue de retry
  • 1 endpoint répondant en plus de 10s: tombe en timeout dans les deux queues et doit donc être exclu temporairement par le circuit breaker

Les tests publient environ 4k messages sur une période de 5min et ont été réalisés avec une scalabilité minimale (1 worker par queue); le but n’étant pas ici de valider notre capacité à prendre de la charge.

Ancienne implémentation

Avec l’ancienne implémentation, le traitement des messages est considérablement ralentit par le traitement des webhooks tombant en timeout au bout de 10s et le fait que les appels en échecs soient auto-retry et republiés dans la même queue au bout de 2 minutes.
En 25 minutes très peu de messages ont été traités.

Dans ce cas, la seule solution pour accélérer le traitement est d’ajouter (upscaler) des workers.

Retry queue & circuit breaker

Avec le circuit breaker et la queue retry l’intégralité des messages sont traités en environ 10 minutes.

Les endpoints pour lequel le circuit breaker a été ouverts seront retry automatiquement tel que décrit précédemment. Si le endpoint ne répond toujours pas le circuit breaker sera de nouveau ouvert pour préserver le throughput.

Queue RabbitMQ principale
Queue RabbitMQ retry

Conclusion

La mise en place de la fail fast policy et surtout du circuit breaker nous ont permis d’être plus résilient en cas de défaillance sur les webhooks et de ne plus être contraint d’upscaler les workers pour dépiler les queues RabbitMQ dans un délai acceptable le tout sans dégrader la délivrabilité de nos webhooks.

--

--