Gestion d'erreurs en Rust avec thiserror et anyhow

1. Aperçu

Rust vous oblige à gérer les erreurs de façon explicite via Result<T, E>. C’est sûr, mais écrire manuellement les types d’erreurs est fastidieux. Vous devez implémenter vous-même Display, Error et From pour chaque cas.

Cet article présente les deux crates les plus populaires pour résoudre ce problème. thiserror vous aide à définir des types d’erreurs structurés. anyhow vous aide à regrouper toutes les erreurs en un seul type et à ajouter du contexte. Après sa lecture, vous saurez quel crate utiliser où, comment les combiner et comment tester les erreurs.

2. Le problème quand on écrit les erreurs manuellement

Supposons que vous lisiez la quantité d’une commande à partir d’un fichier. Deux erreurs peuvent se produire : une erreur d’E/S et une erreur de parsing. Sans utiliser de bibliothèque, vous devriez écrire quelque chose comme :

use std::fmt;

#[derive(Debug)]
pub enum OrderError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

impl fmt::Display for OrderError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            OrderError::Io(_) => write!(f, "impossible de lire le fichier de commande"),
            OrderError::Parse(_) => write!(f, "format de données de commande incorrect"),
        }
    }
}

impl std::error::Error for OrderError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            OrderError::Io(e) => Some(e),
            OrderError::Parse(e) => Some(e),
        }
    }
}

impl From<std::io::Error> for OrderError {
    fn from(e: std::io::Error) -> Self { OrderError::Io(e) }
}

impl From<std::num::ParseIntError> for OrderError {
    fn from(e: std::num::ParseIntError) -> Self { OrderError::Parse(e) }
}

Vous avez besoin de près de 30 lignes de boilerplate pour seulement deux variants. Chaque fois que vous ajoutez un variant, vous devez le modifier à quatre endroits. C’est exactement ce que thiserror fait à votre place.

Un autre raccourci est d’utiliser Box<dyn std::error::Error>. C’est concis, mais cela perd les informations de type et ne vous permet pas d’ajouter du contexte. anyhow est une meilleure version de ce raccourci.

3. Installation

Ajoutez deux dépendances à Cargo.toml :

[dependencies]
thiserror = "2"
anyhow = "1"

Ces deux crates sont indépendants l’un de l’autre. Une bibliothèque n’a généralement besoin que de thiserror. Un binaire utilise généralement anyhow, parfois les deux.

4. thiserror : types d’erreurs structurés pour une bibliothèque

4.1. Définir une énumération d’erreurs

Avec thiserror, le code de la section 2 se réduit à :

use thiserror::Error;

#[derive(Debug, Error)]
pub enum OrderError {
    #[error("commande #{0} introuvable")]
    NotFound(u64),

    #[error("quantité invalide : {quantity} (doit être > 0)")]
    InvalidQuantity { quantity: i32 },

    #[error("impossible de lire le fichier de commande")]
    Io(#[from] std::io::Error),

    #[error("format de données de commande incorrect")]
    Parse(#[from] std::num::ParseIntError),
}

Chaque partie a son propre rôle :

  • #[derive(Error)] génère l’implémentation de std::error::Error.
  • #[error("...")] génère Display. {0} fait référence au premier champ du tuple variant. {quantity} fait référence au champ nommé.
  • #[from] génère impl From<std::io::Error> for OrderError. Grâce à cela, l’opérateur ? convertit automatiquement l’erreur. Le champ avec #[from] devient aussi automatiquement source().

Écrivez maintenant une fonction utilisant ce type d’erreur :

use std::fs;

pub fn parse_quantity(raw: &str) -> Result<i32, OrderError> {
    let quantity: i32 = raw.trim().parse()?;
    if quantity <= 0 {
        return Err(OrderError::InvalidQuantity { quantity });
    }
    Ok(quantity)
}

pub fn load_quantity(path: &str) -> Result<i32, OrderError> {
    let content = fs::read_to_string(path)?;
    parse_quantity(&content)
}

La fonction fs::read_to_string retourne io::Error. L’opérateur ? appelle From pour la convertir en OrderError::Io. De même, ParseIntError est convertie en OrderError::Parse. L’appelant peut faire un match sur chaque variant pour gérer chaque cas.

4.2. Parcourir la chaîne d’erreurs avec #[source]

Parfois, vous voulez conserver l’erreur d’origine mais ne pas la convertir automatiquement via From. Dans ce cas, utilisez #[source] à la place de #[from] :

#[derive(Debug, Error)]
pub enum InventoryError {
    #[error("impossible de mettre à jour le stock pour le produit {sku}")]
    UpdateFailed {
        sku: String,
        #[source]
        cause: std::io::Error,
    },
}

Ce variant a un champ sku supplémentaire, donc From ne peut pas être généré automatiquement. Ici, #[source] déclare simplement que cause est la cause racine. Vous pouvez parcourir la chaîne d’erreurs comme suit :

use std::error::Error as _;

fn print_chain(err: &dyn std::error::Error) {
    eprintln!("Erreur : {err}");
    let mut current = err.source();
    while let Some(cause) = current {
        eprintln!("  due à : {cause}");
        current = cause.source();
    }
}

Quand vous appelez print_chain avec une erreur UpdateFailed encapsulant une erreur “permission denied”, vous verrez :

Erreur : impossible de mettre à jour le stock pour le produit SKU-42
  due à : Permission denied (os error 13)

4.3. #[error(transparent)]

transparent transmet directement Display et source() à l’erreur encapsulée. Vous l’utilisez quand un variant n’est qu’un wrapper et n’ajoute aucune information :

#[derive(Debug, Error)]
pub enum PaymentError {
    #[error("la carte a été refusée")]
    CardDeclined,

    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

Le variant Other joue le rôle d’erreur “tout le reste”. Les erreurs spécifiques que l’appelant doit gérer ont leurs propres variants. Toutes les autres erreurs sont regroupées dans Other.

5. anyhow : un type d’erreur pour toute l’application

Au niveau de l’application, vous utilisez rarement un match sur chaque type d’erreur. Généralement, vous voulez juste enregistrer l’erreur ou l’afficher à l’utilisateur, avec suffisamment d’informations pour comprendre ce qui s’est passé. anyhow::Error répond exactement à ce besoin.

5.1. anyhow::Result et contexte

use anyhow::{Context, Result};

fn read_port(path: &str) -> Result<u16> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("impossible de lire le fichier de configuration {path}"))?;
    let port: u16 = raw
        .trim()
        .parse()
        .context("le port doit être un entier entre 0 et 65535")?;
    Ok(port)
}

fn main() -> Result<()> {
    let port = read_port("app.conf")?;
    println!("Serveur exécuté sur le port {port}");
    Ok(())
}

Le code fonctionne comme suit :

  • anyhow::Result<T> est un alias pour Result<T, anyhow::Error>.
  • Tout type implémentant std::error::Error + Send + Sync + 'static peut être converti en anyhow::Error via ?.
  • .context(...) encapsule l’erreur d’origine dans un nouveau message, et l’erreur d’origine reste stockée comme cause.
  • .with_context(|| ...) prend une closure. La closure n’est exécutée que s’il y a une erreur, donc vous évitez d’appeler format! inutilement sur le happy path.

Quand main retourne une erreur parce que le fichier n’existe pas, le programme affiche :

Error: impossible de lire le fichier de configuration app.conf

Caused by:
    No such file or directory (os error 2)

Vous avez trois façons de formater une anyhow::Error :

  • {} affiche uniquement le message le plus externe.
  • {:#} affiche la chaîne complète sur une seule ligne, séparée par : .
  • {:?} affiche le format multi-lignes comme ci-dessus.

Utiliser {:#} pour enregistrer une seule ligne est très pratique.

Context fonctionne aussi avec Option. C’est un moyen concis de transformer None en erreur significative :

use anyhow::{Context, Result};
use std::collections::HashMap;

fn find_email(customers: &HashMap<u64, String>, id: u64) -> Result<&String> {
    customers.get(&id).with_context(|| format!("pas de client #{id}"))
}

5.2. bail!, ensure! et anyhow!

Ces trois macros vous aident à créer des erreurs ad-hoc sans définir un type :

use anyhow::{anyhow, bail, ensure, Result};

fn apply_discount(total: u64, percent: u8) -> Result<u64> {
    ensure!(percent <= 100, "pourcentage de réduction invalide : {percent}");
    if total == 0 {
        bail!("impossible de réduire une commande de 0 dong");
    }
    total
        .checked_sub(total * percent as u64 / 100)
        .ok_or_else(|| anyhow!("débordement lors du calcul de la réduction"))
}
  • ensure!(cond, ...) retourne une erreur si la condition est fausse.
  • bail!(...) est équivalent à return Err(anyhow!(...)).
  • anyhow!(...) crée une valeur anyhow::Error sans retour.

Par exemple, apply_discount(500_000, 120) retourne l’erreur pourcentage de réduction invalide : 120.

5.3. Downcast quand vous avez toujours besoin de classifier les erreurs

anyhow::Error conserve le type d’origine. Vous pouvez le downcast pour gérer un cas particulier :

fn handle(err: &anyhow::Error) {
    match err.downcast_ref::<OrderError>() {
        Some(OrderError::NotFound(id)) => println!("Affichage de la page 404 pour la commande #{id}"),
        Some(other) => println!("Erreur de commande : {other}"),
        None => println!("Erreur système : {err:#}"),
    }
}

downcast_ref fonctionne même quand l’erreur a été encapsulée par .context(...). Cependant, si vous faites du downcast fréquemment, c’est un signe que vous devriez utiliser une énumération thiserror au lieu de anyhow.

6. Combiner : thiserror en bas, anyhow en haut

Une règle simple et efficace :

  • Bibliothèque ou module métier utilise thiserror. L’appelant a besoin de connaître exactement l’erreur pour décider d’une action.
  • Binaire, CLI, handler au niveau le plus élevé utilise anyhow. Ici, vous rapportez principalement l’erreur.
use anyhow::{Context, Result};

fn process_order_file(path: &str) -> Result<()> {
    let quantity = load_quantity(path)
        .with_context(|| format!("traitement du fichier de commande {path} échoué"))?;
    println!("La commande contient {quantity} produits");
    Ok(())
}

La fonction load_quantity retourne un OrderError structuré. La couche supérieure ajoute du contexte sur le fichier en cours de traitement. Quand vous exécutez avec un fichier contenant -3, vous verrez :

Error: traitement du fichier de commande orders/1001.txt échoué

Caused by:
    quantité invalide : -3 (doit être > 0)

7. Tester le comportement des erreurs

Les types d’erreurs structurés rendent les tests plus précis. Vous pouvez vérifier le bon variant au lieu de comparer simplement des chaînes de façon vague :

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn negative_quantity_returns_invalid_quantity() {
        let err = parse_quantity("-3").unwrap_err();

        assert!(matches!(err, OrderError::InvalidQuantity { quantity: -3 }));
        assert_eq!(err.to_string(), "quantité invalide : -3 (doit être > 0)");
    }

    #[test]
    fn non_numeric_input_returns_parse_error() {
        let err = parse_quantity("abc").unwrap_err();
        assert!(matches!(err, OrderError::Parse(_)));
    }

    #[test]
    fn missing_config_keeps_io_error_as_root_cause() {
        let err = read_port("/n/existe/pas.conf").unwrap_err();

        assert_eq!(
            err.to_string(),
            "impossible de lire le fichier de configuration /n/existe/pas.conf"
        );
        assert!(err.root_cause().downcast_ref::<std::io::Error>().is_some());
    }
}

Exécutez les tests avec :

cargo test
running 3 tests
test tests::negative_quantity_returns_invalid_quantity ... ok
test tests::non_numeric_input_returns_parse_error ... ok
test tests::missing_config_keeps_io_error_as_root_cause ... ok

test result: ok. 3 passed; 0 failed

Le troisième test montre que root_cause() va jusqu’à l’erreur la plus profonde dans la chaîne. Cela vous permet de confirmer que le contexte ne perd pas l’erreur d’origine.

8. Erreurs courantes et quand ne pas utiliser

Attention : Ne répétez pas le message de l’erreur d’origine dans #[error]. Si vous écrivez #[error("io error: {0}")] Io(#[from] io::Error), le message d’origine apparaîtra deux fois quand vous imprimerez la chaîne complète avec {:?} ou {:#}. Choisissez une des deux approches : intégrer {0} et ne pas déclarer source, ou déclarer source et garder le message court.

Attention : N’utilisez pas anyhow::Error dans l’API publique d’une bibliothèque. L’appelant ne peut pas faire un match sur l’erreur et doit utiliser downcast. Exposez plutôt une énumération thiserror.

Voici d’autres erreurs que vous pourriez rencontrer :

  • Deux variants avec le même #[from]. Par exemple, deux variants enveloppant io::Error génèrent deux impl From en conflit et causent une erreur de compilation. Utilisez #[source] combiné avec map_err pour au moins un variant.
  • Abus de #[from]. Si chaque io::Error devient OrderError::Io, vous perdez l’information sur quelle opération a échoué. Un variant avec contexte, comme UpdateFailed { sku, cause }, est souvent plus utile.
  • Oublier que anyhow::Error n’implémente pas std::error::Error. Il se désréférence vers dyn Error mais n’implémente pas le trait. Vous ne pouvez pas le passer là où E: std::error::Error est requis. Si nécessaire, enveloppez-le avec un variant #[error(transparent)].
  • Appeler .context(format!(...)). La chaîne est formatée même s’il n’y a pas d’erreur. Utilisez .with_context(|| ...).
  • Code extrêmement sensible à la performance ou no_std. anyhow::Error alloue sur le heap. Pour un hot path ou un environnement embarqué, une énumération compacte est un meilleur choix.

Si vous voulez une backtrace, définissez la variable d’environnement RUST_BACKTRACE=1 lors de l’exécution. anyhow affichera la backtrace avec l’erreur.

9. Conclusion

thiserror et anyhow répondent à deux besoins différents. thiserror élimine le boilerplate lors de la définition de types d’erreurs, idéal pour les bibliothèques et les couches métier où l’appelant doit classer les erreurs. anyhow regroupe toutes les erreurs en un seul type, permettant d’ajouter du contexte étape par étape, idéal pour les binaires et la couche la plus haute.

Vous devriez combiner les deux : définir des erreurs structurées en bas, ajouter du contexte en haut. Enfin, gardez vos messages courts, n’affichez pas deux fois l’erreur d’origine, et écrivez des tests qui vérifient le bon variant au lieu de simplement comparer des chaînes.

rusterror-handlingthiserroranyhow