Aller au contenu principal

Gestion des erreurs : exceptions, codes de retour ou type Result

Trois mécanismes pour signaler qu'une opération a échoué, et une question qui se pose avant eux : cet échec fait-il partie du fonctionnement prévu, ou signale-t-il une panne ? Le critère de décision, la propagation vers la fonction appelante expliquée pas à pas, des exemples en Python 3.14, Go 1.27 et Rust 1.98, et les six erreurs de gestion qui reviennent le plus souvent en relecture.

Architecture logicielle ·
Adel LATIBI
Adel LATIBI

Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.

En vous inscrivant, vous acceptez de recevoir notre newsletter. Désinscription possible à tout moment.

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.

L'opération échoue La fonction appelante a-t-elle une décision à prendre ? oui non Échec attendu il apparaît dans la signature Échec exceptionnel il remonte, avec du contexte
La question à se poser avant de choisir entre exception, valeur de retour et type Result.

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.

Questions fréquentes

Les exceptions sont-elles plus lentes que les codes de retour ?

Le coût se situe au moment de la levée, quand le langage capture la pile d'appels, pas dans la présence d'un bloc try qui ne déclenche rien. Sur du code qui parle à une base de données ou au réseau, cette différence est noyée dans les temps d'attente. Elle devient mesurable si tu lèves des exceptions dans une boucle serrée, ce qui est déjà un signe que le mécanisme est employé pour du flux normal.

Faut-il utiliser un type Result en Python ou en JavaScript ?

Rien ne l'interdit, et des bibliothèques existent dans les deux écosystèmes. Le point de vigilance est la cohérence : un projet où la moitié du code lève des exceptions et l'autre moitié renvoie des Result devient plus dur à lire que s'il s'était tenu à un seul style. Dans un langage dont la bibliothèque standard signale les erreurs par exception, aller contre le courant coûte plus cher que le gain de rigueur obtenu.

Pourquoi Go continue-t-il d'écrire if err != nil partout ?

Par décision assumée. Trois propositions de syntaxe allégée ont été explorées entre 2018 et 2025, dont une inspirée de l'opérateur ? de Rust, et aucune n'a réuni de consensus. En juin 2025, l'équipe Go a annoncé sur son blog officiel qu'elle cessait de chercher une évolution syntaxique sur ce point et fermait les propositions ouvertes sur le sujet. L'argument avancé tient à la philosophie du langage : rendre le chemin d'erreur visible, même au prix de la répétition.

Que faire d'une erreur qu'on ne sait pas traiter ?

La laisser remonter, en ajoutant du contexte si tu en as à donner : le nom de l'opération, l'identifiant traité, l'étape en cours. La couche qui sait quoi faire est plus haut, souvent au niveau du contrôleur ou du point d'entrée du programme, et c'est elle qui décide entre réessayer, dégrader le service et abandonner.

Faut-il créer une classe d'exception par cas d'erreur ?

Une classe se justifie quand un appelant a besoin d'attraper ce cas précis sans attraper les autres. Si personne n'écrit jamais un bloc dédié à cette exception, elle n'apporte rien qu'un type existant avec un message clair n'apporterait déjà. Le bon dosage se lit dans le code appelant, pas dans la hiérarchie de classes.

Renvoyer None ou null pour signaler un échec, est-ce acceptable ?

Oui dans un cas : quand l'absence est la seule issue négative possible et qu'elle n'a pas besoin d'explication, par exemple une recherche dans un cache. Dès qu'il existe plusieurs raisons d'échouer, ou que l'appelant a besoin de savoir laquelle, la valeur nulle efface l'information et reporte le travail de diagnostic sur celui qui lira les journaux.

Vous êtes expert ?

Partagez votre expertise sur notre blog

Tutoriel, retour d'expérience, analyse - publiez un article invité et gagnez en visibilité.

Écrire pour nous