Dans presque toutes les bases de code qui ont quelques années, il existe un endroit où une erreur est attrapée puis jetée à la poubelle. Un except: pass en Python, un catch (Exception $e) {} en PHP, un _ = err en Go. Ces lignes n'ont pas été écrites par malveillance : il fallait que le programme arrête de s'arrêter, et la question de savoir quoi faire de l'erreur a été repoussée.
Elle revient dès qu'on écrit une fonction qui peut ne pas aboutir. Quand l'opération échoue, qu'est-ce que la fonction renvoie, et qui s'occupe du problème ? Les langages ne répondent pas de la même façon. Python lève une exception, Go retourne une valeur d'erreur à côté du résultat, Rust emballe les deux issues dans un seul type que le compilateur t'oblige à ouvrir.
Présenté comme un affrontement entre écoles, le sujet ne mène nulle part. Chaque camp a ses arguments, aucun ne dit quoi écrire dans la fonction en cours. Il manque une étape en amont du choix du mécanisme, celle où on décide de quelle nature est l'échec qu'on traite.
Cet article pose ce critère, le met à l'épreuve sur un même cas dans les trois langages, s'attarde sur la propagation vers la fonction appelante, qui est le point où la plupart des développeurs débutants décrochent, et se termine par les erreurs de gestion qu'on retrouve le plus souvent en relecture de code.
Une fonction, plusieurs façons de ne pas aboutir
Prends une fonction qui charge un utilisateur à partir de son adresse email. Elle peut trouver la ligne et renvoyer l'objet. Elle peut ne rien trouver, parce que cette adresse n'existe pas. Et la base peut ne pas répondre, parce que le réseau est tombé ou que le pool de connexions est saturé.
Les deux derniers cas sont des échecs, et pourtant ils n'ont rien de comparable. Une adresse inconnue fait partie du fonctionnement normal d'un formulaire de connexion, ça se produit tous les jours, et le code juste au-dessus sait exactement quoi en faire : afficher un message. Une base injoignable n'a pas de réponse locale ; la fonction appelante ne peut rien tenter d'utile, et quelqu'un beaucoup plus haut devra décider s'il faut réessayer, basculer sur un cache ou rendre une erreur serveur.
Traiter ces deux situations avec le même mécanisme produit du code dont on ne peut plus lire la signature pour savoir ce qu'il promet. Une fonction qui renvoie None quand l'utilisateur n'existe pas, et aussi None quand la connexion a lâché, oblige l'appelant à deviner. Une signature qui cache ce qu'elle fait tombe sous le principe de moindre surprise, et le coût se paie plus tard, au moment du débogage.
Le critère : échec attendu ou échec exceptionnel
Un échec attendu appartient au domaine du problème. Un mot de passe qui ne correspond pas, un panier vide au moment de valider la commande, un quota dépassé, un fichier de configuration absent au premier lancement. Il est prévu par les règles métier, il va se produire régulièrement, et la fonction appelante a une décision à prendre.
Un échec exceptionnel signale qu'une hypothèse du programme ne tient plus. Un identifiant censé être unique apparaît deux fois, une dépendance externe est tombée, un index sort du tableau. La fonction appelante n'a rien d'intelligent à en faire, sinon laisser l'information remonter jusqu'à une couche qui a un plan.
Le test qui tranche tient en une question : est-ce que la fonction juste au-dessus a une décision à prendre avec cette information ? Si oui, l'échec mérite d'apparaître dans la signature, sous la forme que ton langage propose. Si non, il doit remonter, et le rôle de la fonction courante se limite à ajouter du contexte au passage.
Faire remonter tôt un échec qu'on ne sait pas traiter rejoint le principe Fail Fast, qui prend la même mécanique par l'autre bout : plus une donnée invalide circule longtemps, plus l'endroit où elle finit par causer un dégât est éloigné de l'endroit où elle est née.
La propagation, le point où ça coince
En formation, la syntaxe de try et except passe en dix minutes. Ce qui bloque arrive juste après. L'idée qu'on attrape une erreur s'installe vite, celle qu'on puisse délibérément ne pas l'attraper beaucoup moins. Le réflexe qui s'installe est d'entourer chaque appel risqué d'un bloc, de logger, et de reprendre le fil comme si de rien n'était. La propagation vers la méthode appelante reste le point le plus long à faire passer, parce qu'elle demande de raisonner sur une fonction qu'on n'est pas en train d'écrire.
Une fonction qui attrape une erreur qu'elle ne sait pas traiter prend une décision à la place de son appelant. Elle déclare l'incident clos, alors que le seul code capable d'en juger se trouve ailleurs, souvent plusieurs niveaux au-dessus, dans le contrôleur HTTP ou dans la boucle qui pilote le traitement.
La règle tient en deux temps. On attrape là où on peut agir : réessayer, prendre une valeur par défaut, afficher un message, rendre un code de statut. Partout ailleurs, on laisse passer. Laisser passer ne veut pas dire se désintéresser de l'erreur, et c'est là que les trois langages proposent leur outil : on l'enrichit en la faisant remonter, pour que celui qui la reçoit sache d'où elle vient.
Le même cas, écrit trois fois
Les exemples qui suivent visent Python 3.14, Go 1.27 et Rust 1.98 en édition 2024. Au moment où ces lignes sont écrites, Python 3.15 est en phase de release candidate et sa sortie finale est annoncée pour le 1er octobre 2026 ; les mécanismes montrés ici ne changent pas d'une version à l'autre.
Python : l'exception typée
# Python 3.14
class UtilisateurIntrouvable(Exception):
"""L'adresse email ne correspond à aucun compte."""
def charger_utilisateur(email: str) -> Utilisateur:
try:
ligne = base.ligne_unique(REQUETE, email)
except ErreurConnexion as err:
raise ErreurChargement(f"chargement de {email}") from err
if ligne is None:
raise UtilisateurIntrouvable(email)
return Utilisateur(**ligne)
Deux échecs, deux traitements. L'utilisateur introuvable a sa propre classe, ce qui permet à l'appelant de l'attraper seul, sans ramasser au passage tout ce qui pourrait mal tourner. La panne de connexion, elle, est relancée avec raise ... from err, qui conserve l'exception d'origine dans la trace au lieu de la remplacer. Côté appelant, la lecture devient directe.
try:
utilisateur = charger_utilisateur(email)
except UtilisateurIntrouvable:
return reponse_identifiants_invalides()
# ErreurChargement n'est pas attrapée ici : elle remonte
Le commentaire de la dernière ligne est le cœur du sujet. Ne pas attraper est un choix explicite, pas un oubli.
Go : la valeur d'erreur retournée
// Go 1.27
var ErrUtilisateurIntrouvable = errors.New("utilisateur introuvable")
func ChargerUtilisateur(ctx context.Context, email string) (Utilisateur, error) {
ligne, err := base.LigneUnique(ctx, requete, email)
if errors.Is(err, sql.ErrNoRows) {
return Utilisateur{}, ErrUtilisateurIntrouvable
}
if err != nil {
return Utilisateur{}, fmt.Errorf("chargement de %s : %w", email, err)
}
return versUtilisateur(ligne)
}
La signature annonce la couleur : cette fonction renvoie un utilisateur et une erreur, et l'appelant devra regarder les deux. Le verbe %w dans fmt.Errorf emballe l'erreur d'origine plutôt que de l'aplatir en texte, ce qui permet à n'importe quelle couche supérieure de la retrouver avec errors.Is ou errors.As. Écrire %v à cette place casse la chaîne et transforme l'erreur en simple message.
C'est le style que la communauté Go reproche le plus à son langage, avec la répétition de if err != nil toutes les cinq lignes. Le sujet a occupé l'équipe pendant sept ans et trois propositions successives, jusqu'à un billet du blog officiel en juin 2025 annonçant l'arrêt de toute recherche de syntaxe dédiée et la fermeture des propositions en cours. Aucune n'avait dégagé de consensus, ni dans l'équipe, ni dans la communauté.
Rust : le type Result et l'opérateur ?
// Rust 1.98, édition 2024
#[derive(Debug)]
enum ErreurChargement {
Introuvable,
Base(std::io::Error),
}
impl From<std::io::Error> for ErreurChargement {
fn from(err: std::io::Error) -> Self {
ErreurChargement::Base(err)
}
}
fn charger_utilisateur(email: &str) -> Result<Utilisateur, ErreurChargement> {
let ligne = lire_ligne(email)?; // io::Error converti par From
let ligne = ligne.ok_or(ErreurChargement::Introuvable)?;
Ok(Utilisateur::from(ligne))
}
Le type de retour énumère les issues possibles, et le compilateur refuse de laisser passer un Result ignoré sans avertissement. L'opérateur ? fait exactement le geste décrit plus haut : si la valeur est un succès, il la déballe et le code continue ; si c'est une erreur, il sort de la fonction en la convertissant au type d'erreur local, grâce à l'implémentation de From. La propagation devient un caractère, et la conversion reste explicite dans le code.
Ce que Rust fournit dans sa bibliothèque standard, d'autres écosystèmes le reproduisent à la main, avec une union discriminée en TypeScript ou une petite classe en Python. Le Result est un motif de conception avant d'être une fonctionnalité de langage, ce qui explique qu'on le retrouve partout depuis quelques années.
Quand l'erreur franchit une frontière
Aucun de ces mécanismes ne traverse le réseau. Dès que ton code expose une API, l'erreur doit être traduite en quelque chose que le client comprend, et la classification faite plus haut se transpose telle quelle : un échec attendu devient un code de la famille 4xx, un échec exceptionnel devient un 500 accompagné d'une trace côté serveur et d'un message avare côté client.
Pour le corps de la réponse, la RFC 9457, publiée en juillet 2023 et remplaçant la RFC 7807, définit un format d'erreur avec les champs type, title, status, detail et instance. L'adopter évite d'inventer un format maison de plus, et donne aux clients de ton API une structure stable à analyser.
Six façons de rater sa gestion d'erreur
Avaler l'erreur
Le bloc vide du début d'article. Il transforme une panne bruyante en comportement silencieux et faux, ce qui coûte des heures de recherche le jour où le bug remonte. Quand tu ne sais pas quoi faire d'une erreur, laisse-la passer plutôt que de la faire disparaître : c'est le seul point du sujet sur lequel les deux écoles sont d'accord depuis trente ans.
Attraper trop large
Un except Exception autour de vingt lignes ne capture pas l'erreur que tu visais, il capture aussi tes propres fautes de frappe, tes erreurs de type et tes accès à des attributs qui n'existent pas. Le bug de programmation prend alors l'apparence d'une erreur métier gérée.
Perdre la cause d'origine
Relancer une exception neuve sans chaîner, emballer avec %v au lieu de %w, écraser la trace par un message générique. L'information la plus utile au débogage est celle qui vient du plus bas de la pile, et elle se perd en une ligne.
Le retour nul qui veut dire deux choses
Renvoyer null pour dire à la fois "absent" et "ça a échoué" est le cas d'école du contrat ambigu. Tony Hoare, qui a introduit la référence nulle en 1965 en concevant le système de types d'ALGOL W, a qualifié cette invention d'erreur à un milliard de dollars sur la scène de QCon London en 2009.
L'exception comme flux normal
Lever une exception pour sortir d'une boucle, ou pour signaler un cas qui se produit à chaque requête, détourne un mécanisme prévu pour l'anormal. Le code devient difficile à suivre, puisque le chemin d'exécution le plus fréquent passe par un saut qui n'apparaît nulle part dans la signature.
Le message qui ne dit rien
Un throw new Exception("erreur") occupe la même place dans le code qu'un message utile, sans en avoir la valeur. Nomme l'opération qui a échoué et les données qui permettent de la rejouer, en gardant hors du message les secrets et les données personnelles, puisque ce texte finit dans des journaux que beaucoup de monde peut lire.
Ce qu'il faut retenir
Le mécanisme dépend du langage et du contexte, la classification ne dépend de rien. Avant d'écrire raise, return err ou Err(...), regarde qui, dans la pile d'appels, a une décision à prendre. La réponse change d'une fonction à l'autre dans le même fichier, et c'est attendu.
Si le sujet t'intéresse au-delà des erreurs, il croise plusieurs des 20 principes de code réunis sur le blog, en particulier la séparation entre commande et requête, qui pose la même exigence de lisibilité sur ce qu'une fonction annonce faire.