L'erreur a deux faces
Une erreur, ça se gère à deux endroits : à l'intérieur de ton code (comment tu la signales) et à la frontière de ton API (comment tu la communiques au client). Ce cours relie les deux.
Les erreurs ne sont pas des cas exceptionnels qu'on traitera « plus tard » : ce sont une partie normale de la vie d'un programme. Un champ manquant, un mot de passe faux, une base injoignable, un bug. La vraie question n'est pas si ça arrivera, mais comment tu réagis — et il y a deux endroits où réagir, qu'on confond souvent.
À l'intérieur de ton code : comment tu signales qu'une opération ne peut pas continuer — c'est le rôle des exceptions.
À la frontière de ton API : comment tu communiques cet échec au client — c'est le rôle des codes de statut HTTP et du format de réponse.
Ce cours relie les deux bout à bout, parce que c'est là que se jouent la plupart des bugs et des frustrations. Le fil conducteur est une seule chaîne :
Problem Details
On commencera par l'intérieur — bien lever, bien attraper, et surtout ne jamais avaler une exception. Puis on franchira la frontière : traduire l'erreur en statut HTTP correct, et la renvoyer dans un format que les clients savent lire, standardisé par les RFC 7807 puis 9457.
200 OK alors que rien n'a marché, ou un corps d'erreur différent à chaque endpoint, est impossible à exploiter proprement. Bien gérer ses erreurs, c'est rendre son API programmable — un client peut réagir intelligemment au lieu de deviner.
Lever, propager, attraper
Une exception, c'est un signal : « je ne peux pas continuer normalement ». Bien utilisée, elle remonte la pile jusqu'à un endroit capable de décider quoi faire. Encore faut-il savoir où l'attraper.
Une exception est un mécanisme simple et puissant : quand une portion de code se trouve dans une situation où elle ne peut pas faire son travail, elle lève une exception. L'exécution normale s'arrête net, et la pile d'appels se « déroule » vers le haut, à la recherche de quelqu'un capable de gérer le problème.
On lève (
throw) une exception là où le problème est détecté. Elle propage en remontant la pile d'appels, traversant chaque fonction qui ne la gère pas. Elle est attrapée (catch) par le premier bloc capable de décider quoi faire — ou, à défaut, elle remonte jusqu'en haut et arrête le programme.
Une distinction selon les langages : certaines exceptions sont vérifiées (checked — le compilateur t'oblige à les gérer ou à les déclarer, comme en Java), d'autres non vérifiées (unchecked — runtime, qu'on n'est pas forcé d'attraper). Beaucoup de langages (PHP, Python, C#…) n'ont que des non vérifiées.
try/catch autour de chaque appel. En réalité, on laisse l'exception remonter jusqu'à un endroit qui sait quoi en faire — souvent une frontière (l'entrée d'une requête). Attraper trop bas, c'est se retrouver avec une exception qu'on ne sait pas traiter… et la tentation de l'avaler.
On organise souvent les exceptions en hiérarchie : une exception générique, et des sous-types précis (« utilisateur introuvable », « solde insuffisant »). Le code qui attrape peut alors choisir sa granularité : traiter un cas précis, ou tout un groupe. Des exceptions au nom métier rendent l'intention limpide — bien plus qu'un message texte noyé dans un log.
L'anti-pattern : avaler une exception
Le piège le plus courant, et le plus sournois : attraper une exception, la logger, et continuer comme si de rien n'était. Le client reçoit un 200, croit que tout va bien — alors que rien n'a été sauvegardé.
Voici la faute la plus répandue, et la plus dangereuse parce qu'elle est silencieuse. On attrape une exception, on écrit une ligne dans les logs… et on continue comme si de rien n'était. C'est avaler l'exception (swallowing).
// ❌ L'anti-pattern : on avale, et on ment au client
try {
employee = saveEmployee(data);
} catch (Exception e) {
log.error("Une erreur est survenue", e);
}
return response(200, employee); // employee vaut null… et on renvoie 200 OK
200 OK — peut-être même avec un employee vide. Côté client : « tout s'est bien passé ! » Alors que rien n'a été enregistré. Une perte de données invisible, jusqu'à ce qu'un utilisateur s'étonne que sa fiche ait disparu. Personne ne verra le log avant qu'il soit trop tard.
Premier réflexe : ce try/catch est souvent inutile. S'il n'y a pas d'exception vérifiée à gérer, et que tu ne sais pas quoi faire de l'erreur ici, ne l'attrape pas — laisse-la remonter. Retirer le bloc ne « casse » rien : ça révèle simplement l'échec au lieu de le cacher.
Si tu attrapes une exception, tu dois en faire quelque chose d'utile : réessayer, basculer sur une alternative, ou la transformer en une réponse d'erreur claire. Logger puis continuer comme si tout allait bien n'est jamais « faire quelque chose ». Dans le doute, laisse remonter.
Au lieu d'avaler, la bonne forme : valider, lever une exception appropriée quand une règle métier est violée, et laisser un gestionnaire d'exceptions (section suivante) la traduire en réponse correcte.
Le simulateur ci-dessous rejoue exactement ce scénario. Bascule entre « avaler » et « laisser remonter », envoie une requête dont la sauvegarde échoue, et regarde ce que reçoit le client.
La sauvegarde va échouer. Compare les deux stratégies :
Traduire une exception en statut HTTP
Une exception métier ne doit pas fuir telle quelle vers le client. On la traduit en réponse HTTP, à un seul endroit : un gestionnaire d'exceptions à la frontière. Jamais de 200 sur un échec.
Une exception métier — « solde insuffisant », « utilisateur introuvable » — ne doit jamais fuir telle quelle vers le client (avec sa pile d'appels et ses détails internes). Il faut la traduire en réponse HTTP. Et le meilleur endroit pour le faire n'est pas dispersé dans le code : c'est un point unique, à la frontière.
Un composant placé au point d'entrée des requêtes, qui attrape les exceptions remontées, et les mappe vers une réponse HTTP : le bon code de statut, et un corps propre. Ton code métier se contente de
throw ; le gestionnaire décide comment ça se présente au client. Une seule responsabilité, un seul endroit.
L'enchaînement devient limpide et la logique métier reste lisible :
// La logique métier lève, sans se soucier du HTTP
if (!user.canAfford(price)) {
throw new InsufficientCreditException(user.balance, price);
}
// Le gestionnaire, à la frontière, traduit
InsufficientCreditException → 403 + corps Problem Details
200 sur un échec. Le code de statut est le premier signal que lit un client (et un cache, et un monitoring). Un échec déguisé en succès empoisonne toute la chaîne en aval.
401/403 — voir Symfony Security avancé.
Le bon code de statut, à chaque fois
4xx, c'est la faute du client ; 5xx, la tienne. Entre les deux, une poignée de codes précis disent exactement ce qui ne va pas. Les confondre, c'est mentir au client.
« Apprends les codes de statut HTTP » : le conseil revient toujours, parce qu'ils sont le vocabulaire commun des erreurs sur le web. Deux familles à distinguer d'abord :
4xx = la faute du client : sa requête est incorrecte, non autorisée, ou porte sur quelque chose qui n'existe pas. Rejouer la même requête échouera pareil.5xx = la faute du serveur : la requête était valide, mais quelque chose a cassé de notre côté. Réessayer plus tard peut marcher.
Cette distinction n'est pas cosmétique : elle dit au client (et aux proxys, et aux mécanismes de retry) qui doit corriger quoi. Renvoyer un 500 pour une faute du client, ou un 400 pour un bug serveur, c'est mentir à toute la chaîne.
| Code | Sens | Exemple |
|---|---|---|
400 Bad Request | Requête mal formée | JSON invalide, paramètre absent |
401 Unauthorized | Non authentifié | Jeton absent ou expiré |
403 Forbidden | Authentifié mais pas autorisé | Pas les droits sur la ressource |
404 Not Found | Ressource inexistante | Id inconnu |
409 Conflict | Conflit avec l'état actuel | Email déjà pris |
422 Unprocessable | Syntaxe ok, mais sémantiquement invalide | Échec de validation métier |
429 Too Many Requests | Débit dépassé | Rate limiting |
500 Server Error | Bug non géré côté serveur | Exception inattendue |
503 Unavailable | Serveur temporairement indisponible | Surcharge, maintenance |
401 = « je ne sais pas qui tu es » (authentifie-toi). 403 = « je sais qui tu es, et tu n'as pas le droit ». Et 422 est le bon choix pour une erreur de validation (le corps est bien formé mais les valeurs sont refusées) — voir Symfony Validator avancé. La famille complète des statuts et leur évolution : HTTP · De 1.1 à 3.
Le simulateur ci-dessous déclenche différents échecs. Pour chacun, vois l'exception levée et le statut correct — 4xx ou 5xx.
Déclenche un échec et observe le statut adapté :
Quand chaque API invente son erreur
Le bon statut ne suffit pas : il faut aussi un corps de réponse exploitable. Or chaque API invente le sien — un vrai casse-tête côté client. D'où une norme : Problem Details.
Le bon statut HTTP est nécessaire, mais pas suffisant. Un 400 dit « ta requête est mauvaise » — mais pourquoi ? Quel champ ? Le client a besoin d'un corps de réponse exploitable. Et là, c'est l'anarchie.
Chaque API invente sa propre forme d'erreur. Le même problème — un champ invalide — peut prendre dix visages :
| API A | API B | API C |
|---|---|---|
{ "error": "bad email" } |
{ "message": "...", "code": 12 } |
{ "errors": [ ... ] } |
La solution est exactement celle qu'on a vue pour d'autres problèmes du web : se mettre d'accord sur un standard. Pour les erreurs HTTP, ce standard a un nom — Problem Details — et c'est l'objet des deux sections suivantes.
RFC 7807 : un format pour les erreurs
Un seul format, machine-lisible, pour toutes tes erreurs : c'est la RFC 7807. Cinq champs standard, un type de média dédié, et la liberté d'ajouter les tiens. Fini les blobs maison.
La RFC 7807 (« Problem Details for HTTP APIs », 2016) répond exactement au chaos précédent : un format unique, machine-lisible, pour décrire une erreur dans le corps d'une réponse HTTP.
Une réponse Problem Details se sert d'un en-tête
Content-Type dédié : application/problem+json (ou application/problem+xml). Rien qu'à ça, un client sait qu'il tient une erreur structurée à interpréter, et non une réponse métier normale.
Le corps contient cinq champs standard — tous optionnels, mais cadrés :
| Champ | Rôle |
|---|---|
type | Une URI qui identifie la catégorie de problème (l'identifiant primaire). Par défaut about:blank. |
title | Résumé court et lisible, stable pour un même type (hors traduction). |
status | Le code HTTP, recopié dans le corps par commodité (doit correspondre au vrai statut). |
detail | Explication propre à cette occurrence, orientée « comment corriger ». |
instance | Une URI qui identifie cette occurrence précise du problème. |
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://exemple.com/probs/credit-insuffisant",
"title": "Crédit insuffisant.",
"status": 403,
"detail": "Ton solde est de 30, l'opération en coûte 50.",
"instance": "/compte/12345/operations/abc",
"balance": 30,
"accounts": ["/compte/12345", "/compte/67890"]
}
Au-delà des cinq champs, tu ajoutes les tiens — ici
balance et accounts. C'est là qu'un type de problème porte ses données spécifiques. Règle clé : un client doit ignorer les extensions qu'il ne connaît pas — ce qui permet de faire évoluer un type sans rien casser.
type est la clé. C'est lui, et non le title, que le client doit utiliser pour reconnaître un problème par programme. Le title est pour les humains ; le type (une URI stable) est pour le code. Idéalement, cette URI mène à une page qui documente le problème et comment le résoudre.
Le simulateur ci-dessous construit la réponse Problem Details selon le type d'erreur — et la compare à un format maison non standard.
Choisis un type d'erreur et observe la réponse produite :
RFC 9457 : ce que la suite apporte
En 2023, la RFC 9457 remplace la 7807. Mêmes fondations, mais des manques comblés : un registre de types communs, et surtout une façon standard de renvoyer plusieurs erreurs d'un coup.
En 2023, la RFC 9457 remplace officiellement la 7807 (on dit qu'elle l'obsolète). Pas de révolution : mêmes cinq champs, même type de média, mêmes extensions. Mais plusieurs manques de la 7807 sont comblés.
1. Un registre IANA de types communs. Plutôt que chacun invente ses URIs de
type, un registre officiel héberge des types de problèmes réutilisables — pour des catégories d'erreurs fréquentes, partagées entre API.2. Le cas « plusieurs problèmes ». La 9457 cadre comment renvoyer plusieurs erreurs à la fois, typiquement via un membre d'extension
errors portant une collection — exactement ce qu'il faut pour la validation de formulaire.3. Les URIs non résolvables. Des conseils clairs pour les
type qu'on ne peut pas déréférencer (par exemple des tag URIs), et la recommandation d'utiliser des URIs absolues quand c'est possible.
Le point le plus utile au quotidien, c'est le second. Avec la 7807, renvoyer une liste d'erreurs de validation se faisait « à la main », chacun sa convention. La 9457 standardise l'idée :
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://exemple.com/probs/validation",
"title": "La requête contient des champs invalides.",
"status": 422,
"errors": [
{ "detail": "L'email est invalide.", "pointer": "/email" },
{ "detail": "Le nom est obligatoire.", "pointer": "/name" }
]
}
errors s'il ne le connaît pas). C'est le bénéfice du principe « ignore les extensions inconnues ». Pour la validation côté Symfony, qui produit naturellement des listes d'erreurs par champ, vois Symfony Validator avancé.
Le pipeline complet
De l'exception levée au corps application/problem+json : récapitulons la chaîne, et les réflexes qui font la différence entre une API frustrante et une API sur laquelle on peut programmer.
On a relié les deux faces de l'erreur. Le pipeline complet, de l'intérieur vers la frontière, tient en une ligne :
Les réflexes à garder
- Ne jamais avaler une exception. Logger puis continuer comme si de rien n'était est une perte de données silencieuse. Dans le doute, laisse remonter.
- Attraper au bon endroit — une frontière qui sait quoi faire — pas autour de chaque ligne.
- Jamais de
200sur un échec. Le statut est le premier signal, pour le client comme pour les proxys et le monitoring. - Le bon code : 4xx pour la faute du client, 5xx pour la tienne ; et le code précis (401 ≠ 403, 422 pour la validation).
- Un format normalisé : Problem Details (
application/problem+json), idéalement RFC 9457, avec untypestable comme identifiant et des extensions pour le reste. - Sépare logs et réponse : les détails techniques pour toi, une réponse propre et sans fuite pour le client.
Gérer les erreurs, c'est une chaîne continue : une exception qui porte du sens, attrapée à la frontière, traduite dans le bon statut HTTP, et renvoyée dans un format que tout client sait lire. Bien faite, cette chaîne rend ton API programmable et ton système diagnosticable. Bâclée, elle transforme chaque incident en enquête.
Territoires voisins : HTTP · De 1.1 à 3 (les statuts), Symfony Validator (les erreurs de validation), Symfony Security (401/403), Timeouts (les erreurs de délai et le 504) et Fondamentaux de l'architecture backend (la gestion d'erreur dans un système distribué).