Achille 746
ACHILLE746
ExpertisesProcessusRésultatsTechnologiesBlogFAQ
Lancer un projet
🦀RUST

La gestion d'erreurs en Rust : Result, ? operator et patterns de production

5 JUIN 2026•Par l'équipe Achille 746•8 min de lecture

La gestion d'erreurs est l'un des aspects du développement logiciel qui distingue le code de prototype du code de production. La plupart des langages laissent ce domaine à la discrétion du développeur : les exceptions peuvent être ignorées, les nulls peuvent se propager silencieusement, et les chemins d'erreur ne sont souvent testés qu'après le premier incident. Rust prend le parti inverse. Il n'y a pas d'exceptions, pas de valeurs nulles implicites, pas de chemins d'erreur qui se faufilent sans être vus par le compilateur. Cette contrainte peut sembler pénalisante en début de projet — elle est en réalité un investissement dont les dividendes se mesurent en incidents évités.

Mais la robustesse par construction ne suffit pas. Il faut encore maîtriser les outils que l'écosystème Rust met à disposition : le type Result, l'opérateur ?, les crates thiserror et anyhow, et les patterns qui distinguent une gestion d'erreurs naïve d'une gestion d'erreurs exploitable en production. Ce guide couvre l'ensemble de cette chaîne, des fondamentaux aux stratégies par couche applicative.

Result<T, E> : le fondement de la gestion d'erreurs

En Rust, toute opération pouvant échouer retourne un Result<T, E>. Il s'agit d'un enum à deux variantes : Ok(T) encapsule la valeur en cas de succès,Err(E)encapsule l'erreur en cas d'échec. Le compilateur impose l'exhaustivité : un Result non traité génère un avertissement, et ignorer silencieusement une erreur nécessite un choix explicite, jamais un oubli.

Le traitement d'un Result s'effectue principalement par pattern matching. Un match exhaustif sur les deux variantes est la forme la plus explicite. Pour les cas simples, if let Ok(val) = result traite uniquement le succès, et if let Err(e) = resultuniquement l'échec. Les méthodes fonctionnelles de Result permettent de chaîner les transformations : maptransforme la valeur en cas de succès sans toucher à l'erreur, map_errtransforme l'erreur sans toucher à la valeur,and_then enchaîne une opération qui peut elle-même échouer, or_elsetente une alternative en cas d'échec, unwrap_or_elsefournit une valeur de repli calculée à partir de l'erreur.

Les méthodes unwrap() et expect() sont des raccourcis qui extraient la valeur ou paniquent si le résultat est Err. Ce sont des panics déguisés : ils terminent le thread en cours avec un message d'erreur et une backtrace. En tests unitaires,unwrap()est parfaitement acceptable — un test qui panique est un test qui échoue, ce qui est le comportement attendu. En code de production, c'est une bombe à retardement. Chaque unwrap()sur un chemin critique est un incident potentiel : une base de données temporairement indisponible, un fichier de configuration mal formé, une réponse HTTP inattendue, et le service s'arrête. La règle est simple : unwrap() en tests, jamais en prod.

La comparaison avec try/catch révèle une différence de philosophie fondamentale. Dans les langages à exceptions, une erreur est un flux de contrôle alternatif qui peut traverser n'importe quelle couche de code sans être déclaré. En Rust, une erreur est une valeur ordinaire. Elle figure dans la signature de la fonction, elle se manipule avec les mêmes outils que n'importe quelle autre valeur, et elle ne peut pas se propager sans être explicitement transmise. Cette visibilité totale est ce qui rend le code Rust si fiable à long terme.

L'opérateur ? : propagation élégante des erreurs

L'opérateur ?est un sucre syntaxique introduit pour rendre la propagation d'erreurs naturelle. Placé après une expression de type Result, il extrait la valeur si le résultat est Ok, ou effectue un early returnavec l'erreur convertie si le résultat est Err. En pratique, là où un code naïf nécessiterait unmatch à chaque appel susceptible d'échouer, l'opérateur ?permet d'écrire une chaîne d'opérations lisible comme du code synchrone.

La conversion implicite est au cœur de son fonctionnement. Quand l'opérateur ?rencontre un Err(e), il appelle automatiquement From::from(e)pour convertir le type d'erreur vers le type attendu par la fonction englobante. Cette mécanique, basée sur le trait From, permet à une fonction retournantResult<T, MonErreur> de propager des erreurs de types variés — erreurs I/O, erreurs de parsing, erreurs de base de données — à condition que chaque type source implémenteFrom<SourceErr> for MonErreur.

L'opérateur ? ne fonctionne que dans les fonctions dont le type de retour estResult (ou Option). Il peut se chaîner sur plusieurs opérations successives : let contenu = fs::read_to_string(chemin)?.parse::<Config>()?;lit un fichier et le parse en une ligne, en propageant l'erreur de lecture ou de parsing selon ce qui échoue en premier.

Sa principale limitation est la compatibilité des types. Si deux opérations retournent des types d'erreur différents et qu'aucune implémentation Fromn'existe, l'opérateur ? ne compilera pas. La tentation de contourner ce problème avecBox<dyn Error>comme type d'erreur universel est réelle, mais elle efface l'information structurelle de l'erreur, rendant le traitement côté appelant plus difficile. Autre piège : enchaîner des ? sur une conversion qui perd du contexte. Si une erreur de base de données devient une erreur générique IoErrorpar le jeu des conversions From, les logs de production perdent l'information nécessaire au diagnostic.

thiserror : définir ses propres types d'erreurs

Dans les bibliothèques et les composants réutilisables, les types d'erreur méritent d'être définis explicitement. Box<dyn Error>est tentant pour sa généricité, mais il cache aux appelants la nature réelle des erreurs possibles : impossible de savoir par le type si une erreur est récupérable, si elle doit être loguée, ou si elle correspond à un cas métier attendu. Un type d'erreur custom exprime ces distinctions au niveau du système de types.

La crate thiserror fournit une macro derivequi élimine le boilerplate associé à la définition d'erreurs custom. En annotant un enum avec #[derive(Debug, Error)], chaque variante peut être documentée avec #[error("message {champ}")] pour définir son affichage, #[source] pour désigner la cause racine (ce qui implémente automatiquement Error::source()), et #[from] pour générer une conversionFrom automatique depuis un type sous-jacent.

Le pattern recommandé pour une bibliothèque est de distinguer les erreurs publiques — l'interface que les appelants voient et gèrent — des erreurs internes, qui sont des détails d'implémentation. Une erreur publique de type StorageError::NotFound ou StorageError::Conflictdonne à l'appelant les informations nécessaires pour réagir. Une erreur interne comme un timeout de connexion au pool SQL est enveloppée dans la variante appropriée via #[from]et se propage avec son contexte intact grâce à #[source].

Pour une application web complète, une hiérarchie typique distingue les erreurs de stockage (qui wrappent les erreurs sqlx ou std::io), les erreurs de domaine (ValidationError, NotFound, Unauthorized, Conflict), et les erreurs HTTP (qui mappent les précédentes vers des codes de statut et un format JSON). Chaque couche est responsable de sa propre hiérarchie et expose aux couches supérieures uniquement ce qu'elles ont besoin de savoir.

anyhow : gestion d'erreurs ergonomique pour les applications

anyhow est conçu pour un problème différent de thiserror. Dans les binaires, les outils CLI et les services applicatifs, les appelants finaux ne manipulent pas les erreurs par pattern matching — ils les loguent et, selon la gravité, retournent une réponse d'erreur ou terminent le programme. Dans ce contexte, la richesse structurelle du type d'erreur importe moins que la richesse du contexte fourni pour le diagnostic.

anyhow::Errorest un type opaque qui encapsule n'importe quelle valeur implémentant std::error::Error. L'alias anyhow::Result<T>est équivalent à Result<T, anyhow::Error>. L'opérateur ?devient universel dans les fonctions retournant ce type : n'importe quelle erreur peut se propager sans conversion manuelle, car anyhow::Error implémente From<E>pour tout E: Error + Send + Sync + 'static.

La valeur ajoutée d'anyhow réside dans ses méthodes de contexte. .context("lecture du fichier de config")ajoute un message explicatif sans effacer la cause originale. .with_context(|| format!("traitement de l'utilisateur {id}"))évalue le message paresseusement, ce qui est utile quand la construction du contexte a un coût. La macro anyhow!crée une erreur ad hoc à partir d'un message formaté, pour les cas où aucun type d'erreur structuré n'est nécessaire. L'ensemble produit des messages d'erreur en chaîne lisibles : « démarrage du serveur : lecture du fichier de config : fichier non trouvé ».

La règle d'or de l'écosystème Rust est donc claire : thiserror pour les bibliothèques, anyhow pour les binaires et les services. Une bibliothèque expose un type d'erreur structuré que ses utilisateurs peuvent inspecter et gérer. Un service applicatif, en revanche, est le consommateur final des erreurs — il les logue, les monitore, et décide comment répondre, sans qu'un appelant externe ait besoin de les filtrer par type.

Contexte et backtrace : le débogage en production

La robustesse d'un système se juge autant à sa capacité à éviter les erreurs qu'à sa capacité à les diagnostiquer rapidement quand elles surviennent malgré tout. En Rust, deux variables d'environnement contrôlent la capture automatique des backtraces : RUST_BACKTRACE=1active les backtraces pour les panics et RUST_LIB_BACKTRACE=1 les active pour les erreurs retournées via anyhow. En production, activer ces variables permet de corréler instantanément une erreur à son origine dans le code, sans avoir à reproduire le problème.

Dans les applications async basées sur Tokio, les backtraces classiques sont moins informatives : les futures s'exécutent sur des threads du pool, et la pile d'appel ne reflète pas le contexte métier de la tâche. La crate tracing-error apporte une solution avec les span traces : les spans ouverts par la crate tracingsont enregistrés sur l'erreur au moment où elle est créée, fournissant un chemin d'exécution logique plutôt qu'une pile système. Ce contexte structuré — ID de requête, ID utilisateur, opération en cours — est précisément ce qui fait la différence entre un log exploitable et un log qui demande trente minutes d'enquête.

Le pattern de production recommandé est d'enrichir les erreurs avec du contexte métier au plus près de leur création — l'ID de la transaction en cours, la requête HTTP qui a déclenché l'opération, l'état du système au moment de l'échec — et de les propager enrichies jusqu'à la frontière du service. C'est à cette frontière, et uniquement là, que l'erreur est loguée dans son intégralité avec tracing::error!ou envoyée vers un système de capture comme Sentry ou OpenTelemetry. À l'intérieur du service, les types d'erreurs riches circulent librement.

Erreurs dans les contextes async et multi-thread

Dès qu'un type d'erreur est utilisé dans des futures exécutées par Tokio, il doit implémenter Send + Sync. C'est une exigence du compilateur : les futures peuvent être déplacées entre threads par le runtime, et une erreur non Send bloquerait ce mécanisme. Box<dyn Error + Send + Sync> satisfait cette contrainte mais efface la structure. anyhow::Error est déjà Send + Sync par construction, ce qui en fait un choix naturel pour le code async.

La gestion des panics dans les tâches Tokio mérite une attention particulière. tokio::spawnretourne un JoinHandle<T> dont l'appel .await retourneResult<T, JoinError>. Si la tâche a paniqué, le JoinError en témoigne. Ignorer ce Result signifie ignorer silencieusement un panic dans une tâche de fond — un des bugs les plus difficiles à diagnostiquer en production. Toutes les tâches critiques doivent avoir leur JoinHandle surveillé.

Pour les sections où un panic ne doit pas arrêter le système — code interagissant avec des bibliothèques tierces non fiables, parsing de données externes, exécution de code dans une sandbox — std::panic::catch_unwindpermet d'intercepter un panic et de le convertir en Result. Cette technique est correcte pour les cas isolés mais ne remplace pas une gestion d'erreurs rigoureuse. Un panic handler personnalisé, enregistré via std::panic::set_hook, permet de logguer les panics avec contexte et de notifier les systèmes de monitoring avant que le thread ne s'arrête.

Patterns de production : stratégies par couche

Une application bien conçue n'utilise pas la même stratégie de gestion d'erreurs dans toutes ses couches. Chaque couche a des responsabilités différentes, et sa stratégie doit en découler.

La couche de stockage et d'I/Oest en contact direct avec les erreurs techniques : timeout de connexion, contrainte d'unicité violée, fichier inaccessible. Ces erreurs sont définies avec thiserror et convertissent transparentement les erreurs des crates sous-jacentes (sqlx::Error, std::io::Error) via#[from]. L'appelant reçoit un type structuré qu'il peut inspecter.

La couche de logique métiertravaille avec des erreurs de domaine : entrée invalide, entité introuvable, accès non autorisé, état conflictuel. Ces erreurs expriment ce qui s'est passé dans les termes du domaine fonctionnel, pas dans les termes de l'infrastructure. Une erreur ReservationError::Conflictest compréhensible par n'importe quel membre de l'équipe ; une sqlx::Error::Databaseavec code 23505 ne l'est que par les développeurs connaissant le schéma PostgreSQL.

La couche HTTP et API (Axum, par exemple) implémente IntoResponsesur le type d'erreur applicatif. Chaque variante mappe vers un code de statut HTTP approprié et un corps JSON au format RFC 7807 (Problem Details). Les erreurs Unauthorizeddeviennent des 401, les NotFound des 404, les Conflict des 409, et les erreurs inattendues des 500 avec un identifiant de corrélation pour relier la réponse client au log serveur.

La couche d'observabilité est transversale à toutes les autres.tracing::error!à la frontière du service logue l'erreur complète avec son contexte structuré — span trace, ID de requête, durée de l'opération. Les intégrations Sentry et OpenTelemetry capturent ces événements en production pour les agrégation, les alertes et les analyses post-mortem. La règle est de ne pas logguer deux fois la même erreur : une seule fois à la frontière, jamais en profondeur où l'erreur sera loguée à nouveau par chaque couche traversée.

Anti-patterns à éviter

Plusieurs habitudes issues d'autres langages deviennent des anti-patterns en Rust, et il est utile de les nommer explicitement pour les éviter.

  • unwrap/expect en dehors des tests : chaque unwrap() dans du code de production est un panic potentiel. Les remplacer par une propagation ?ou par un traitement explicite du cas d'erreur.
  • String comme type d'erreur : Result<T, String>est le minimum absolu. Une chaîne de caractères ne porte aucune structure — l'appelant ne peut pas filtrer, comparer ni réagir différemment selon le type d'erreur sans parser du texte.
  • Ignorer les erreurs avec _ = func(): un résultat ignoré est silencieusement jeté. C'est légitime dans de rares cas (flush d'un buffer en fermeture d'application), mais doit toujours être commenté pour signaler que c'est un choix délibéré.
  • Box<dyn Error> dans les APIs publiques: ce type efface l'information et rend les tests d'intégration difficiles. Réservé aux prototypes ; remplacer par un type structuré en production.
  • panic!() pour des cas d'erreur prévus: si un cas peut se produire en production — données invalides, ressource indisponible, cas limite — ce n'est pas un cas qui mérite un panic. C'est un cas qui mérite un type d'erreur.
  • Cloner les erreurs inutilement: les types d'erreur ne devraient généralement pas implémenter Clone ni Copy. Cloner une erreur pour la conserver en parallèle de la propagation est souvent le signe d'une architecture à revoir.

« Dans les langages à exceptions, les erreurs sont des cas particuliers. En Rust, les erreurs sont des valeurs de première classe — aussi ordinaires qu'un entier ou une chaîne. Cette banalisation de l'erreur est ce qui force les développeurs à la traiter systématiquement, et c'est précisément ce qui produit des systèmes fiables. »

— Principe fondateur de la philosophie de gestion d'erreurs en Rust

La gestion d'erreurs en Rust a une courbe d'apprentissage réelle. Les premières semaines, le compilateur semble exigeant à l'excès : il force le traitement de cas d'erreur que d'autres langages permettraient d'ignorer. Mais cette exigence est un investissement. Les systèmes qui en sortent sont des systèmes dont les chemins d'erreur ont tous été pensés, dont les conversions de types ont été explicitement conçues, et dont le comportement en conditions dégradées a été réfléchi avant le premier déploiement. En production, cela se traduit par des incidents moins fréquents, des diagnostics plus rapides, et une confiance accrue dans un code que le compilateur a déjà validé avant que l'équipe d'astreinte ne soit sollicitée.

Article précédentSupply chain attacks : sécuriser son pipeline CI/CD en 2026Tous les articlesArticle suivantWebAssembly avec Rust : performances natives dans le navigateur et au-delà
Partager cet article :Twitter / XLinkedIn

Articles liés

Rust en 2026 : Le langage le plus sécurisé pour les applications critiques →Axum et Tokio : construire une API REST haute performance en Rust →

Vous développez des systèmes Rust robustes pour la production ?

Nous concevons et développons des applications Rust production-ready — gestion d'erreurs, observabilité et fiabilité by design.

Discuter de votre projet →