July 24, 2024

Design d'API HTTP: synchrone vs asynchrone

Lorsqu’on conçoit une API HTTP, on se retrouve vite confronté à un problème: certaines actions peuvent être longues et rentrent difficilement dans un modèle d’API synchrone. Comment résoudre cela ?

Le problème

Dans les API HTTP (et surtout les API REST-like), on utilise généralement des requêtes de type GET pour récupérer de la donnée et des requêtes POST, PUT, DELETE pour des modifications (création, mise à jour, suppression).

Récupérer de la donnée (requêtes GET) se fait de manière synchrone: une requête est envoyée au serveur, la donnée est récupérée et retournée directement au client. On peut bien sûr avoir des mécanismes de paging losqu’on doit lister beaucoup de données, mais cela reste synchrone.

Lorsqu’on crée ou modifie une donnée, c’est plus compliqué. Je pense que l’on peut distinguer deux types de modification pour une entité donnée.

Requête de modification simple

Imaginons un cas fictif où un utilisateur veut changer son addresse postale. Dans bien des cas, ce type de modification ne nécessitera qu’une mise à jour en base de données.

Ces requêtes simples peuvent être implémentées comme des appels synchrones, l’action étant de plus complètement atomique: soit ça passe, soit ça passe pas.

Requête déclenchant un traitement complexe

Mais la création ou mise à jour d’entité peuvent être beaucoup plus long. En effet, on peut avoir à déclencher plusieurs actions côté backend.

Dans certains cas, aller au bout du traitement demande une logique de workflow, où plusieurs services sont impliqués. En microservice, c’est également assez commun d’avoir besoin de coordonner plusieurs services (via un bus de message par exemple) pour réaliser une action, tout faire de manière transactionnelle et synchrone n’étant pas possible.

J’ai par exemple travaillé pendant plusieurs années chez un cloud provider. Lorsqu’un utilisateur demande à créer une machine virtuelle, un load balancer, un cluster Kubernetes…​ cela peut prendre de quelques secondes à quelques minutes selon le type d’action.

Sans tomber dans des extrêmes, des traitements de plusieurs secondes ou supérieurs à 10 secondes sont courants. Impossible dans ce cas de faire du synchrone via HTTP: on risque de rapidement avoir des timeouts ou autres problèmes réseau, et d’un point de vue UX ce n’est pas très joli. Ce serait intéressant de ne jamais avoir de requêtes lentes, quel que soit l’action à réaliser, non ?

Etude de cas

Une solution élégante à ce problème est je pense l’API d’Exoscale. Je la connais bien car j’ai participé à la refonte (réécriture complète avec passage en "v2") de cette API il y a quelques années.

Comme dit précédemment, beaucoup d’actions chez un cloud provider sont lentes donc il faut trouver des solutions pour éviter de faire du synchrone.

Regardons comment Exoscale résout ce problème en créant un load balancer via sa CLI en mode debug (avec les logs un peu nettoyés):

time EXOSCALE_TRACE=true exo compute load-balancer create example

// 1.
POST /v2/load-balancer
HTTP/2.0 200 OK
{"id":"ee3869a0-493a-11ef-a552-2d24f88feb86","state":"pending","reference":{"id":"2e7da9a0-c6bd-4075-b5a1-dfc90d43f245","link":"/v2/load-balancer/2e7da9a0-c6bd-4075-b5a1-dfc90d43f245","command":"get-load-balancer"}}

// quelques secondes plus tard...
// 2.
GET /v2/operation/ee3869a0-493a-11ef-a552-2d24f88feb86
HTTP/2.0 200 OK
{"id":"ee3869a0-493a-11ef-a552-2d24f88feb86","state":"success","reference":{"id":"2e7da9a0-c6bd-4075-b5a1-dfc90d43f245","link":"/v2/load-balancer/2e7da9a0-c6bd-4075-b5a1-dfc90d43f245","command":"get-load-balancer"}}

Créer un load balancer prend à peu près 5 secondes au total via la CLI. Mais en fait, chaque appel HTTP est extrêmement rapide. Voici comment cela fonctionne:

1.

La requête POST /v2/load-balancer est envoyée pour créer le load balancer. L’API répond immédiatement. Trois choses sont intéressantes dans la réponse:

  • Une clé id est retournée: c’est un ID d’opération.

  • La clé state ayant comme valeur pending nous informe que l’opération demandée n’est pas terminée

  • La clé reference nous donne des informations sur l’entité en cours de création, notamment son ID ou comment la récupérer. En effet, on sait déjà à la création de la ressource son UUID, ce qui est génial pour pouvoir déjà l’identifier de manière fiable.

A ce moment, l’entité existe déjà. Elle peut être récupérée, elle est visible dans l’interface, mais elle n’est pas totalement créée (et donc toujours inutilisable).

2.

La requête GET /v2/operation/ee3869a0-493a-11ef-a552-2d24f88feb86 arrive quelques secondes après et permet de voir si l’opération est terminée ou non. Si oui, le state sera en success (c’est le cas ici) et on sait que la ressource est créée. Si il est encore pending, on continue de poll.

Remarquez qu’on tape ici une API spécifique, /v2/operation/<operation-id>. L’ID de l’opération est ici l’ID retourné lors de la première requête de création.

Que l’action dure 5 secondes ou 3 minutes, la façon de fonctionner est toujours la même sur l’API d’Exoscale, pour l’ensemble des appels modifiant une ressource (création, mise à jour, suppression). Tout fonctionne via des ID d’opérations associés à un status.

Le meilleur des deux mondes

Ce que je trouve intéressant avec l’API d’Exoscale, c’est sa cohérence. Comme dit précédemment toutes les actions de modification fonctionnent de cette manière, pour l’ensemble de l’API.

Le polling n’est pas une nouveauté, mais penser à ce type de design en amont est super important. Sans cela, on a tendance à faire du pure synchrone jusqu’au jour où on a des appels de plus en plus lents ou complexes, et on est coincé. Et à ce moment là, on commence à perdre en consistance sur le fonctionnement de l’API, ce qui a des conséquences sur les services la consommant, le tooling, le frontend, et donc les clients. Ici, tout est unifié et on sait toujours à quoi s’attendre.

De plus, il est possible d’éviter le polling pour les actions de modification qui sont en réalité synchrones, comme dans mon exemple précédent où un utilisateur mettait à jour son adresse postale.
Il suffit dans ce cas de retourner dès le premier appel une opération en state: success pour indiquer au client qu’il n’a pas à poll l’API jusqu’à la fin de l’opération, celle-ci étant déjà terminée.

Bref, avec une approche asynchrone par défaut, vous aurez le meilleur et deux mondes et vous ne serez pas bloqué pour des besoins futurs.

Tags: programming

Add a comment








If you have a bug/issue with the commenting system, please send me an email (my email is in the "About" section).

Top of page