Écrire une API idempotente (exemple en PHP avec Symfony)

D’après la RFC 9110 qui spécifie la sémantique d’une requête HTTP, une requête est considérée comme idempotente si elle peut être effectuée plusieurs fois et obtenir un résultat identique lors de chaque appel. Par exemple, si l’on tente d’accéder à une ressource REST GET /resource/42, deux appels à cet endpoint retournera toujours le même résultat. Les appels GET à une API REST sont alors considérés comme idempotent (il en va de même pour des appels PUT ou DELETE par exemple). Mais ce n’est pas le cas d’un appel POST /resource qui créera alors une nouvelle ressource à chaque appel. Nous allons voir dans ce billet comment rendre un appel POST idempotent.

Pour rendre une API idempotente, il va falloir être en mesure d’identifier de manière unique un appel à une ressource. Généralement, le client API va générer une clé unique et l’ajouter à l’appel effectué. La clé est ensuite vérifiée par le serveur pour qu’il puisse savoir si la requête reçue a déjà été traitée ou non:

  • Si la clé n’est pas connue, le traitement va être effectué et le résultat sauvegardé avant de retourner la réponse HTTP.
  • Dans le cas contraire, le serveur va retourner la réponse précédemment enregistrée précédemment.

La plupart du temps, la clé d’impotence est envoyée dans les en-têtes de l’appel HTTP afin d’éviter de polluer le corps de la requête.

Prenons par exemple, le code source d’une API permettant de créer un billet de blog:

class BlogController extends AbstractController
{
    #[Route('/api/articles', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        $post = new stdClass();
        $post->id = Uuid::v7();
        $post->title = $request->request->get('title', 'Default title');
        $post->createdAt = new DateTimeImmutable();
        // [...] enregistrement en base de données

        return new JsonResponse($post, JsonResponse::HTTP_CREATED);
    }
}

Comme expliqué précédemment, cette API n’est pas idempotente. Car si une même requête est effectuée deux fois de suite, un même article My awesome article sera ajouté en base de données.

Nous allons maintenant ajouter à notre API la gestion d’une clé permettant de s’assurer de l’unicité du traitement d’une requête. Cette clé doit être envoyée par le client dans une en-tête que nous nommerons X-Idempotent-Key. Cette clé sera stockée dans un système de cache (via le composant symfony/cache) pendant une durée d’une heure. Si la clé n’existe pas, elle sera créée et dans le cas contraire, la réponse du traitement de la requête sera renvoyée une nouvelle fois depuis les données mises en cache.

Cela pourrait conduire à l’implémentation suivante:

class BlogController extends AbstractController
{
    public function __construct(
        private readonly Psr\Cache\CacheItemPoolInterface $cache,
    ) {}

    #[Route('/api/blog', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        if (
            // si l'en-tête X-Idempotent-Key est présente
            ($idempotentKey = $request->headers->get('X-Idempotent-Key')) !== null

            // et que la donnée est en cache
            && ($postCache = $cache->getItem($idempotentKey))->isHit()
        ) {
            // on retourne la réponse présente dans le cache
            return new JsonResponse($postCache->get(), JsonResponse::HTTP_CREATED);
        }

        // dans le cas contraire, on effectue le traitement

        $post = new stdClass();
        $post->id = Uuid::v7();
        $post->title = $request->request->get('title', 'Default title');
        $post->createdAt = new DateTimeImmutable();
        // [...] enregistrement en base de données

        // dans le cas où la clé d'idempotence est définie
        if ($idempotentKey !== null) {
            // on enregistre le résultat du traitement en cache
            $item = $cache->getItem($idempotentKey);
            $item->set($post);
            $item->expiresAfter(3600)
            $cache->save($item);
        }

        return new JsonResponse($post, JsonResponse::HTTP_CREATED);
    }
}

De cette manière notre API pourra être idempotente, améliorant ainsi sa fiabilité ainsi que la cohérence des données du service. L’idempotence permet de garantir que les requêtes identiques produisent toujours le même résultat, évitant ainsi les doublons involontaires qui pourraient survenir en cas de problème réseau par exemple.

Faut-il mettre en place ce mécanisme partout ? Pas nécessairement. Focalisez les opérations les plus critiques de votre système et en particulier les opérations de modification de l’état de ce dernier.