Comment gérer les erreurs dans une API Symfony

En tant que développeur, nous devons tous gérer des erreurs, des erreurs souvent dites métiers comme le fait de ne pas trouver une ressource en base de données. Dans ce cas-là, un code HTTP 404 est retourné. Il n’y a pas de règle absolue pour la gestion des erreurs, chacun est libre de les gérer comme il l’entend, elle peut être présente dans des controllers, dans des modèles, ou dans des services type Usecase, etc… Nous nous retrouvons potentiellement alors à avoir une gestion d’erreurs spécifiques pour des comportements similaires, et inévitablement, nous creusons la dette technique.

Dans cet article, nous vous proposons une façon de centraliser votre gestion d’erreurs. Pour cet exemple, notre point de départ est une API Symfony, le code sera en PHP, mais la philosophie reste la même pour n’importe quelle autre technologie.

Le spécifique, c’est pas automatique

Nous aimons avoir des outils génériques à notre disposition dans le but d’éviter de réinventer la roue en permanence. Dans la mesure du possible, nous regardons ce qui peut être commun dans notre code. Pourquoi avoir deux fois le même code qui réalise les mêmes instructions ?

  • x maintiens du code à ses différents endroits
  • x fois plus de complexité
  • x traitements spécifiques
  • x tests à produire

Il en manque probablement, mais nous nous retrouvons à jouer à un jeu d’équilibriste dans notre application et à devoir jongler avec des try catch.

Un Event Listener pour les traiter toutes

Concrètement, qu’est-ce qu’un Event Listener dans Symfony (il existe l’équivalent en GO, Java…) ? Comme son nom l’indique, il s’agit d’un “écouteur d’événement”. Dès qu’un événement va être enclenché, dans notre cas une Exception, nous aurons la possibilité d’agir à “haut niveau”, c’est-à-dire dans du code déporté de notre EventLoop (tâche main depuis votre action) pour englober toutes les exceptions de notre API et non depuis vos fichiers PHP quelque soit leur type.

La solution que nous vous proposons est de dispatcher à travers un switch les différentes instances que peut prendre notre Exception. Imaginons que nous créions une exception BookNotFoundException qui étend de classe EntityNotFoundException, automatiquement nous savons que nous serons dans un cas de code 404. Mais comme un exemple vaut bien plus que de la théorie…

Ici, nous déclarons notre service Symfony avec le tag qui va nous permettre d’intercepter l’événement “levée d’exception” comme suit :

# config/services.yaml
services:
    App\EventListener\ExceptionListener:
        tags:
            - { name: kernel.event_listener, event: kernel.exception }

Notre service ExceptionListener qui contiendra une méthode onKernelException par laquelle le processus passera si une exception est levée :

<?php

declare(strict_types=1);

….
use JMS\Serializer\SerializerInterface;
use Symfony\Component\Translation\Translator;

final class ExceptionListener
{
   public function __construct(
       private readonly SerializerInterface $serializer,
       private readonly Translator $translator
   ) {
   }

   public function onKernelException(ExceptionEvent $event): void
   {
       $exception = $event->getThrowable();

       if ($exception instanceof AsYouWantException) {
           …
       } else {
           $this->handleException($event, $exception);
       }
   }

   private function handleException(ExceptionEvent $event, Throwable $exception): void
{
   $event->setResponse(
       new Response(
           $this->serializer->serialize(
               [
                   'errors' => new ErrorRepresentation(
                       $this->translator->trans($exception->getMessage()),
                   ),
               ],
               ‘json’
           ),
           $exception->getStatusCode(),
           ['Content-Type' => 'application/json']
       )
   );
}

Vous pouvez voir la présence de JMS Serializer et de Symfony Translator dans le constructeur de notre classe qui nous permettent respectivement d’afficher et de traduire nos messages d’erreurs. Quant à la réponse qui est en Json, elle est directement injectée dans notre événement qui lui, aura la charge de retourner le message d’erreur depuis le webservice interrogé.

Voilà un exemple de fichiers yaml contenus dans le dossier Translations/ pour les traductions :

#translations/messages.fr.yaml
book:
 not_found: "Livre non trouvé en base de données."

À noter que la ligne $exception->getMessage() de notre Event Listener sera égale à book.not_found qui sera elle-même traduite par notre composant Translator. Arrivés à ce stade, nous avons centralisé une chose : la remontée d’erreurs.

Enfin, tout ce qu’il nous reste à faire, c’est de créer une classe dont le rôle sera d’afficher nos messages de manière compréhensible :

…
use JMS\Serializer\Annotation as Serializer;

final class ErrorRepresentation
{
   public function __construct(
       #[Serializer\Expose, Serializer\Type('string')]
       public readonly string $message
   )
   {
   }
}

Pour aller plus loin…

Il y a des concepts que nous n’avons pas traités ou juste survolés, comme la gestion des différents codes HTTP pour retourner un message spécifique, les normes d’API Rest et l’utilisation de Json Api, les erreurs liées à la validation de formulaire, peut-être lors d’un futur article !

Quelques liens pratiques