← Tous les cours / Gérer les erreurs : des exceptions au format Problem Details Cours
00 — VUE D'ENSEMBLE

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.

Les deux faces d'une erreur
À 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 :

Exception levée
Statut HTTP adapté
Réponse normalisée
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.

💡 Pourquoi ça compte : une API qui renvoie 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.
01 — LES EXCEPTIONS

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.

Lever, propager, attraper
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.

🔑 Où attraper ? Le plus haut utile, pas le plus bas possible. L'erreur de débutant est de mettre un 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.

📦 Un choix d'architecture, pas une obligation. Beaucoup d'applications regroupent leurs exceptions métier dans une couche dédiée ; c'est une organisation parmi d'autres, utile à grande échelle, pas une règle universelle. Ce qui compte partout : des exceptions qui portent du sens, et qu'on attrape au bon endroit.
02 — NE PAS AVALER

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
🤮 Pourquoi c'est grave. La sauvegarde a échoué, mais l'exception a été étouffée. Le code poursuit, et renvoie 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.

Échouer fort, pas en silence
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 :

03 — DE L'EXCEPTION AU STATUT

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.

Le gestionnaire d'exceptions (exception handler)
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 :

Validation / règle métier
throw exception
Gestionnaire → statut + corps
// 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
InsufficientCreditException403 + corps Problem Details
🚫 La règle d'or, répétée : jamais de 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.
🔒 Sépare le log et la réponse. Les détails techniques (pile d'appels, message d'exception brut) vont dans tes logs, pour toi. La réponse au client reste propre et sans fuite d'information sensible. Deux publics, deux niveaux de détail. Côté Symfony, les exceptions d'authentification et d'accès se mappent ainsi vers 401/403 — voir Symfony Security avancé.
04 — LES BONS CODES

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 contre 5xx
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.

CodeSensExemple
400 Bad RequestRequête mal forméeJSON invalide, paramètre absent
401 UnauthorizedNon authentifiéJeton absent ou expiré
403 ForbiddenAuthentifié mais pas autoriséPas les droits sur la ressource
404 Not FoundRessource inexistanteId inconnu
409 ConflictConflit avec l'état actuelEmail déjà pris
422 UnprocessableSyntaxe ok, mais sémantiquement invalideÉchec de validation métier
429 Too Many RequestsDébit dépasséRate limiting
500 Server ErrorBug non géré côté serveurException inattendue
503 UnavailableServeur temporairement indisponibleSurcharge, maintenance
💡 401 vs 403, le duo qu'on confond. 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é :

05 — LE CHAOS DES FORMATS

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 AAPI BAPI C
{ "error": "bad email" } { "message": "...", "code": 12 } { "errors": [ ... ] }
😵 Le coût du chaos. Un client qui consomme plusieurs API (ou plusieurs microservices) doit écrire un parseur d'erreurs différent pour chacune. Pire : au sein d'une même API, deux endpoints écrits par deux équipes divergent. Impossible d'écrire un traitement d'erreur générique et fiable. Chacun réinvente la roue, mal.

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.

🔑 Standardiser ≠ tout aplatir. Un bon standard fixe une structure commune et laisse la place aux spécificités de chaque API. C'est précisément ce que fait Problem Details : des champs communs que tout client comprend, plus des extensions libres.
06 — PROBLEM DETAILS

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.

Le type de média
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 :

ChampRôle
typeUne URI qui identifie la catégorie de problème (l'identifiant primaire). Par défaut about:blank.
titleRésumé court et lisible, stable pour un même type (hors traduction).
statusLe code HTTP, recopié dans le corps par commodité (doit correspondre au vrai statut).
detailExplication propre à cette occurrence, orientée « comment corriger ».
instanceUne 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"]
}
Les membres d'extension
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.
💡 Le 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 :

07 — RFC 9457

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.

Ce qui change avec 9457
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" }
  ]
}
💡 En pratique : vise directement la 9457 pour tes nouvelles API — elle est rétrocompatible avec la 7807 (un client 7807 lira sans peine les cinq champs standard et ignorera 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é.
08 — SYNTHÈSE

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 :

règle métier violée
throw exception
gestionnaire
statut HTTP
application/problem+json

Les réflexes à garder

Ce que tu emportes
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é).