400 ou 422 ? Concevoir les erreurs de validation d'une API Symfony

Arnaud Pointet · · 9 min de lecture

Read this article in English

Cet article part d'un exercice, à faire dans le navigateur : Laisser remonter une ValidationFailedException →

Depuis Symfony 8.2, une ValidationFailedException qui n'est pas attrapée ne donne plus une erreur 500 : Symfony la transforme lui-même en réponse 422, avec la liste des violations. L'exercice de Pratique qui présente ce changement consiste surtout à supprimer du code.

Mais derrière cette ligne en moins, il y a une vraie question de conception, que je vois souvent mal tranchée dans les API que j'audite : que doit répondre une API quand la requête est invalide ? Quel code HTTP, quel format, et quelles informations pour que le client puisse réagir ? Cet article part de l'exercice et fait le tour de la question.

L'exercice : une ligne en moins

Un contrôleur d'inscription validait un objet à la main, puis devait envelopper l'exception de validation dans une exception HTTP :

$violations = $validator->validate($inscription);
if (\count($violations) > 0) {
    throw new UnprocessableEntityHttpException(
        'Inscription invalide.',
        new ValidationFailedException($inscription, $violations),
    );
}

Sans cette enveloppe, l'exception devenait une erreur 500. Depuis Symfony 8.2, il suffit de la lancer :

if (\count($violations) > 0) {
    throw new ValidationFailedException($inscription, $violations);
}

La réponse est alors exactement celle que produit #[MapRequestPayload] quand la validation échoue :

{
    "type": "https://symfony.com/errors/validation",
    "title": "Validation Failed",
    "status": 422,
    "detail": "email: This value is not a valid email address.\npseudo: This value is too short. It should have 2 characters or more.",
    "violations": [
        {
            "propertyPath": "email",
            "title": "This value is not a valid email address.",
            "template": "This value is not a valid email address.",
            "parameters": { "{{ value }}": "\"pas-un-email\"" },
            "type": "urn:uuid:bd79c0ab-ddba-46cc-a703-a7a4b08de310"
        }
    ]
}

(Seule la première violation est reproduite ici ; celle du pseudo suit le même modèle.)

Avant d'aller plus loin, voyons comment Symfony arrive à ce résultat. Ce mécanisme vous servira pour vos propres exceptions.

Comment Symfony choisit le code HTTP d'une exception

La classe ValidationFailedException porte maintenant un attribut :

#[WithHttpStatus(422)]
class ValidationFailedException extends RuntimeException

Quand une exception remonte jusqu'au noyau, l'ErrorListener de Symfony cherche cet attribut (y compris sur les classes parentes). S'il le trouve, il enveloppe l'exception dans une HttpException avec le bon code : exactement ce que le contrôleur faisait à la main. Ensuite, si le composant Serializer est installé et que le client attend du JSON (en-tête Accept: application/json, ou format json sur la route), le Serializer met en forme l'erreur. Sinon, la 422 est rendue en HTML : c'est la première chose à vérifier quand « ça ne marche pas » avec un simple curl. Son ProblemNormalizer remarque que l'exception d'origine est une erreur de validation, et délègue la liste des violations au ConstraintViolationListNormalizer.

Le même attribut fonctionne pour vos exceptions métier :

#[WithHttpStatus(409)]
final class PseudoDejaPris extends \DomainException
{
}

Un détail rassure souvent les puristes de l'architecture hexagonale : un attribut PHP ne crée pas de dépendance à l'exécution. Tant que personne ne lit les attributs par réflexion, la classe WithHttpStatus n'est même pas chargée. Votre domaine peut porter l'attribut sans dépendre réellement de HttpKernel. Si vous préférez ne rien écrire dans le domaine, la configuration fait la même chose de l'extérieur :

# config/packages/framework.yaml
framework:
    exceptions:
        App\Domaine\PseudoDejaPris:
            status_code: 409
            log_level: info

400, 422, 409 : à chaque problème son code

Le code HTTP est la première chose que lit un client, souvent la seule qu'il lit automatiquement. Il doit donc dire qui doit corriger quoi.

  • 400 Bad Request : la requête est illisible, par exemple un JSON mal formé. Le client a un bug : un humain doit corriger le code qui envoie la requête.
  • 415 Unsupported Media Type : le Content-Type annonce un format que l'API ne lit pas. Même conséquence : c'est le code du client qu'il faut corriger.
  • 422 Unprocessable Content : la requête est lisible et bien formée, mais son contenu est invalide. L'adresse e-mail n'en est pas une, le pseudo est trop court. Souvent, c'est l'utilisateur final qui doit corriger sa saisie, et le client doit lui afficher les erreurs à côté des bons champs.
  • 409 Conflict : la requête est valide, mais elle entre en conflit avec l'état actuel de la ressource. Le pseudo est déjà pris, la commande a déjà été expédiée, la version modifiée n'est plus la dernière.

Le 422 vient de WebDAV, mais il fait partie de la sémantique HTTP générale depuis la RFC 9110. Il n'y a plus de raison de l'éviter.

Symfony suit déjà cette distinction avec #[MapRequestPayload] : un format non pris en charge donne une 415, un corps JSON illisible une 400, alors qu'un champ du mauvais type (une chaîne là où on attend un entier) ou une contrainte non respectée donne une 422, avec les violations.

Le cas du pseudo déjà pris

« Ce pseudo est déjà utilisé » : 409 ou 422 ? Les deux se défendent. J'ai une préférence pour 422 avec une violation sur pseudo, pour une raison pratique : le client affiche cette erreur exactement comme les autres, sous le champ concerné. C'est ce que fait la contrainte UniqueEntity du pont Doctrine de Symfony.

Mais il faut alors penser au cas que la contrainte ne voit pas. Deux inscriptions simultanées avec le même pseudo passent toutes les deux la validation, puis la seconde se heurte à l'index unique de la base. Sans traitement, c'est une UniqueConstraintViolationException de Doctrine, donc une 500. Je la convertis en violation, pour que le client reçoive la même réponse dans tous les cas :

try {
    $this->entityManager->flush();
} catch (UniqueConstraintViolationException) {
    throw new ValidationFailedException($inscription, new ConstraintViolationList([
        new ConstraintViolation(
            'Ce pseudo est déjà utilisé.',
            'Ce pseudo est déjà utilisé.',
            [],
            $inscription,
            'pseudo',
            $inscription->pseudo,
            code: UniqueEntity::NOT_UNIQUE_ERROR,
        ),
    ]));
}

Le code UniqueEntity::NOT_UNIQUE_ERROR n'est pas un détail : sans lui, la violation sort sans membre type, et le client perd le seul repère stable dont il dispose (voir plus bas). Et rien d'autre dans ce catch : après cette exception, Doctrine a fermé l'EntityManager, un second flush() échouerait.

Avec Symfony 8.2, cette conversion tient en un throw, sans HttpException autour.

Le format : Problem Details

Le JSON produit par Symfony suit le format Problem Details for HTTP APIs, normalisé par la RFC 9457 (qui a remplacé la RFC 7807). Le format définit cinq membres standards :

  • type : une URI qui identifie la sorte de problème. C'est elle que le client devrait tester, pas le titre ;
  • title : un résumé lisible de cette sorte de problème, identique d'une occurrence à l'autre ;
  • status : le code HTTP, répété dans le corps ;
  • detail : l'explication de cette occurrence ;
  • instance : une URI qui identifie cette occurrence précise, pratique pour retrouver une erreur dans les journaux.

Tout le reste, comme violations, est une extension prévue par la norme. Adopter ce format, c'est permettre aux clients d'utiliser des bibliothèques existantes plutôt que de découvrir un format maison de plus. La RFC recommande aussi le type de média application/problem+json, dont certains clients se servent pour détecter une erreur. Symfony le pose de lui-même dès que le corps a un type, un title et un status ; une réponse d'erreur construite à la main, elle, doit y penser.

Ce dont le client a vraiment besoin

C'est là que la plupart des API échouent. Elles renvoient des erreurs pour un humain qui lit la réponse, alors que c'est un programme qui la reçoit.

Un chemin qui correspond à la requête

propertyPath doit désigner le champ tel que le client l'a envoyé. Si votre API reçoit du snake_case et que vos DTO sont en camelCase, le normaliseur applique le name converter configuré dans le Serializer. Le chemin devient date_de_naissance et non dateDeNaissance, à condition que ce convertisseur soit bien configuré. Pour une structure imbriquée, le chemin suit la forme adresses[0].codePostal, ce qui permet au client de retrouver la ligne fautive dans un formulaire répété.

Un code stable, pas un message

Le title d'une violation est un message traduit : il change avec la langue, et le jour où quelqu'un corrige une faute dans un fichier de traduction. Un client qui teste title === "This value is too short..." cassera tôt ou tard.

Le membre type de chaque violation, lui, est stable : urn:uuid: suivi du code de la contrainte. Par exemple, Length::TOO_SHORT_ERROR et Email::INVALID_FORMAT_ERROR sont des UUID figés dans le code de Symfony. Les vôtres aussi, si vous écrivez des contraintes personnalisées : donnez-leur des constantes d'erreur avec un UUID. C'est ce code que le client doit tester pour afficher son propre message, ou déclencher un comportement particulier.

Documentez ces codes avec votre API (dans votre schéma OpenAPI, par exemple). C'est la partie la plus utile, et la plus souvent oubliée, d'un contrat d'erreur.

Les données utiles, et seulement celles-là

La valeur invalide, elle, n'est pas renvoyée en tant que telle : ValidationFailedException::getValue() n'est pas sérialisée. Seul le paramètre {{ value }} de certains messages la reprend. Pensez-y pour les champs sensibles : un mot de passe refusé par une contrainte Regex ne doit pas revenir en clair dans la réponse, ni surtout dans vos journaux.

En mode debug, la réponse inclut en plus la classe de l'exception et la trace complète. C'est précieux en développement, et c'est une fuite d'informations si APP_DEBUG reste actif en production. Ce n'est pas une hypothèse : je le rencontre régulièrement en audit.

Les journaux : une 422 n'est pas un incident

Symfony journalise les erreurs HTTP 4xx au niveau warning. C'est d'ailleurs nouveau en 8.2 pour les exceptions qui reçoivent leur code par #[WithHttpStatus] ou framework.exceptions, comme ValidationFailedException : avant, elles sortaient en error, et réveillaient le handler fingers_crossed de la recette Monolog. Pour une API publique, les erreurs de validation font partie du fonctionnement normal : des milliers de saisies incorrectes par jour, autant de lignes warning, et les vrais avertissements se perdent dans le bruit. Je les descends d'un niveau :

framework:
    exceptions:
        Symfony\Component\Validator\Exception\ValidationFailedException:
            log_level: info

À l'inverse, je garde une alerte sur le taux de 422 par point d'entrée. Une hausse brutale après un déploiement, c'est souvent un client mobile qui n'a pas été mis à jour, ou une contrainte devenue trop stricte. Une erreur de validation isolée est normale, une vague d'erreurs est un incident.

Tester le contrat, pas les messages

Les tests de l'exercice vérifient ce qu'un client attend vraiment : le code 422, le type du problème et la liste des champs en erreur.

$this->assertResponseStatusCodeSame(422);
$probleme = json_decode((string) $client->getResponse()->getContent(), true);
$this->assertSame('https://symfony.com/errors/validation', $probleme['type']);
$this->assertSame(['email', 'pseudo'], array_column($probleme['violations'], 'propertyPath'));

Aucun test sur le texte des messages. Si vous voulez aller plus loin, vérifiez aussi le type de chaque violation : c'est lui qui fait partie du contrat que vous avez promis à vos clients.

Ce que j'en retiens

La nouveauté de Symfony 8.2 est petite : une exception qui sait quel code HTTP elle représente. Mais elle rend cohérent ce qui ne l'était pas : la validation à la main, #[MapRequestPayload] et la conversion d'une erreur de base de données passent désormais par le même chemin, et produisent la même réponse.

Le reste dépend de vous : choisir le bon code, garder un format standard, donner aux clients des codes stables plutôt que des phrases, et ne pas confondre une saisie invalide avec un incident.

L'exercice se fait en quelques minutes dans le navigateur, sur une version de développement de Symfony 8.2. Les deux requêtes de l'onglet « Requêtes » montrent la réponse avant et après.

← Tous les articles