https://lafor.ge/feed.xml

Gérer ses erreurs en Rust

2022-08-11

Bonjour à toutes et tous 😀

Les erreurs en Rust sont un vaste sujet.

Au fur et à mesure que l’application se complexifie, la gestion des erreurs devient cruciale.

Le problème est que chaque bibliothèque expose son propre système d’erreurs.

Si votre projet utilise un certain nombre de bibiliothèques, vous pouvez vous trouver dans la situation où vous devez réaliser des adaptations de code pour permettre aux erreurs d’être compatibles entre-elles.

Avant de se plonger dans la crate eyre que je souhaite vous présenter, nous allons faire un petit tour d’horizon de ce qui existe déjà.

Rétrospective de la gestion d’erreurs

La situation d’urgence : le panic

Il existe en Rust une manière d’interrompre une exécution. Il s’agit du mécanisme de panic.

fn will_fail() {
    panic!("Ce programme meurt ici et maintenant")
}

fn main() {
    will_fail();
    println!("Ne sera jamais affiché");
}

L’idée est simple, si le code arrive dans une instruction de panic, l’exécution se stoppe immédiatement et le programme retourne la stack trace.

thread 'main' panicked at 'Ce programme meurt ici et maintenant', src\main.rs:2:5
stack backtrace:
   2: error_reporting::will_fail
             at .\src\main.rs:2
   3: error_reporting::main
             at .\src\main.rs:6
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
error: process didn't exit successfully: `target\debug\error-reporting.exe` (exit code: 101)

Ce mécanisme permet de préserver un système d’un état qui ne peut pas être géré et qui doit provoquer son extinction pur et simple.

Le fichier de configuration n’est pas trouvable et il n’y a pas de valeur par défaut.

Le gros point faible de ce système, est qu'une fois qu’un programme a paniqué, il est difficile (mais pas impossible) de le récupérer.

Information

Si vous voulez effectuer une récupération d’un panic, il existe un méthode catch_unwind, qui permet de faire poursuivre le programme en capturant la panique.

fn main() {
    catch_unwind(will_fail);
    println!("Hello world");
}

Par contre, ce système n’est pas conçu pour vous permettre de faire un mécanisme de try/catch. Il est uniquement là pour assurer la compatibilité avec des libs en C par exemple.

Utilisation d’un booléen comme status de retour

La réponse naïve dans la gestion d’erreur consiste à renvoyer un booléen true/false.

Il indique l’état d’un traitement.

Généralement:

  • true : tout s’est bien passé
  • false : une erreur est survenue
fn process() -> bool {
    true
}

fn main() {
    if process() {
        println!("Succès")
    } else {
        println!("Échec")
    }
}

Utilisation d’un code de retour numérique

Ce système marche bien lorsqu’il n’y a que deux états possibles.

Sinon l’on doit faire comme en C où l’on utilise des codes de retour.

  • 0 : tout c’est bien passé
  • 1 : une erreur recouvrable est survenue
  • autre chose : une erreur impossible à résoudre est survenue
fn process() -> u8 {
    2
}

fn main() {
    match process() {
        0 => println!("Succès"),
        1 => println!("Erreur"),
        _ => panic!("Crash"),
    }
}

Ce système rudimentaire, permet de pouvoir gérer plus finement le déroulé de notre programme. Mais ce n’est pas encore suffisant.

La gestion idiomatique des erreurs en Rust

Rust possède son propre système de gestion d’erreur basé sur une énumération Result.

Celle-ci se compose ainsi:

enum Result<T, E> {
    Ok(T),
    Err(E)
}

Les deux variantes de l’énumération étant génériques. Le type de la valeur de retour et le type de l’erreur ne sont pas contraints.

Nous allons créer notre propre erreur au travers d’une enumération:

enum MyError {
    Error,
    Failure,
}

Nous alors utiliser l’objet Rusult comme retour de notre méthode process.

fn process(input: i8) -> Result<u8, MyError> {

    if input < 0 {
        return Result::Err(MyError::Failure);
    }

    if input > 10 {
        return Result::Err(MyError::Error);
    }

    Result::Ok(input + 1)
}

Puis traiter l’erreur éventuelle, au travers d’un match.

fn main() {

    match process(41) {
        Ok(val) => {
            println!("Succès");
            println!("val : {val}");
        }
        Err(MyError::Error) => {
            println!("Erreur le nombre est supérieur à 10");
        }
        Err(MyError::Failure) => panic!("Le nombre ne peut pas être négatif"),
    };

Utiliser cette énumération nous ouvre certaines possibilitées comme la propagation des erreurs.

Propagation d’erreur

Imaginons qu’une erreur soit levée dans un appel profond de notre programme.

fn deep() -> Result<(), MyError> {
    Err(MyError::Error)
}

fn process(input: u8) -> Result<u8, MyError> {
    let result = deep();
    println!("After deep");
    Ok(input + 1)
}

Si l'on exécute la fonction process, nous allons voir s’afficher :

After deep`

Or, nous ne voulons pas ça.

Nous voulons que l’exécution de process se stoppe si la méthode deep est en erreur.

Pour ce faire, nous devons propager l’erreur venant de la méthode deep.

fn process(input: u8) -> Result<u8, MyError> {
    let result = deep();

    if let Err(error) = result {
        return Err(error);
    }

    println!("After deep");
    Ok(input + 1)
}

Après cette modification nous n’avons plus le After deep dans la console. Nous avons réalisé un early return de notre erreur.

Un ou deux sucre avec votre erreur ?

Le fait d’utiliser un Result de la stdlib nous ouvre aussi la porte à l’utilisation d’une syntaxe particulière qui permet de propager une erreur.

Celle-ci consiste à rajouter un ? à la fin de l’appel d’une fonction retournant un Result.

Ceci provoquera le retour immédiat de l’erreur si celle-ci survient.

fn process(input: u8) -> Result<u8, MyError> {
    deep()?;
    println!("After deep");
    Ok(input + 1)
}

Bien sûr, ce processus peut se réaliser à n’importe quelle profondeur d’exécution.

fn below_deep() -> Result<(), MyError> {
    Err(MyError::Error)
}

fn deep() -> Result<(), MyError> {
    below_deep()?;
    Ok(())
}

fn process(input: u8) -> Result<u8, MyError> {
    deep()?;
    println!("After deep");
    Ok(input + 1)
}

Ce qui permet de se concentrer sur l’essentiel c’est à dire le déroulé du code lorsqu’il y a pas d’échec.

Contexte d’erreur

Savoir que le code est en erreur est une chose, savoir pourquoi il l’est, c’est encore mieux.

Pour cela, nous allons aggrémenter notre erreur d’un contexte qui nous permettra de mieux cerner le souci.

enum MyError {
    Error(String),
    Failure,
}

Au lieu de renvoyer une simple erreur, nous allons également rajouter du contexte sous la forme d’une chaîne de caractères.

fn below_deep() -> Result<(), MyError> {
    Err(MyError::Error(
        "occurs into below_deep function".to_string(),
    ))
}

fn deep() -> Result<(), MyError> {
    below_deep()?;
    Err(MyError::Error("occurs into deep function".to_string()))
}

Puis, nous pouvons alors afficher cette Erreur avec plus de détails.

fn main() {

    let val = match process(old_val) {
        Ok(val) => {
            println!("Succès");
            println!("val : {val}")
        }
        Err(MyError::Error(details)) => {
            println!("Erreur: {details}");
        }
        Err(MyError::Failure) => panic!("Crash"),
    };

    
}

Cela nous donne dans la console:

Erreur: occurs into below_deep function

Nous sommes désormais, dans la capacité de déterminer qu’une erreur s’est produite, et pourquoi elle s’est produite.

Mais, nous ne savons pas où elle s’est produite.

Pour cela nous allons devoir agrémenter un peu notre code.

Capturer la backtrace dans le contexte

Il existe une crate qui se nomme backtrace-rs qui est capable d’effectuer ce travail pour nous.

D’abord, nous installons la crate.

cargo add backtrace

Puis, nous allons ensuite modifier notre structure d’erreur.

Ensuite, nous transformons la variante Error en une structure possédant deux champs:

  • details qui contient les détails de l’erreur
  • backtrace la callstack amenant à l’erreur
enum MyError {
    Error {
        backtrace: Backtrace,
        details: String,
    },
    Failure,
}

Pour nous faciliter la vie nous créons un contructeur

impl MyError {
    fn new(details: &str) -> Self {
        let backtrace = Backtrace::new();

        MyError::Error {
            backtrace,
            details: details.to_string(),
        }
    }
}

Que nous utilisons dans la suite du code

fn below_deep() -> Result<(), MyError> {
    Err(MyError::new("occurs into below deep function"))
}

fn deep() -> Result<(), MyError> {
    below_deep()?;
    Err(MyError::new("occurs into deep function"))
}

On modifie également la façon d’afficher les erreurs, pour rajouter la notion de backtrace.

fn main() {

    let val = match process(old_val) {
        Ok(val) => {
            println!("Succès");
            println!("val : {val}");
    
        }
        Err(MyError::Error { details, backtrace }) => {
            println!("Erreur: {details} \n {backtrace:?}");
        }
        Err(MyError::Failure) => panic!("Crash"),
    };

    println!("val : {val}")
}

Ce qui nous donne à l’exécution l’erreur suivante:

Erreur: occurs into below deep function 
   5: enum$<error_reporting::MyError, 1, 18446744073709551615, Error>::new
             at src\main.rs:13
   6: error_reporting::below_deep
             at src\main.rs:32
   7: error_reporting::deep
             at src\main.rs:36
   8: error_reporting::process
             at src\main.rs:41
   9: error_reporting::main
             at src\main.rs:49

Si on décrit cette stacktrace sous la forme d’un graph cela nous donne:

  flowchart LR
    main --> process --> deep --> below_deep -.-> MyError::new

Sauf que le MyError::new est de trop dans l’histoire, il vient polluer la stacktrace avec une étape inutile.

Pour éviter cet appel à new, nous allons utiliser une macro qui va effectuer le tavail à notre place.

Si vous avez un trou de mémoire ou que ne connaissez pas le principe des macros, j’ai rédiger un article dessus. 😀

macro_rules! raise {
    ($details:expr) => {
        Err(MyError::Error {
            backtrace: Backtrace::new(),
            details: $details.to_string(),
        })
    };
}

Que l’on utilise ainsi:

fn below_deep() -> Result<(), MyError> {
    raise!("occurs into below deep function")
}

fn deep() -> Result<(), MyError> {
    below_deep()?;
    raise!("occurs into deep function")
}

Cette fois-ci la stacktrace devient:

Erreur: occurs into below deep function 
   5: error_reporting::below_deep
             at src\main.rs:21
   6: error_reporting::deep
             at src\main.rs:25
   7: error_reporting::process
             at src\main.rs:30
   8: error_reporting::main
             at src\main.rs:38

  flowchart LR
    main --> process --> deep --> below_deep

Ce qui est mieux. 😊

Qualifier sa chaîne d’erreurs

Il serait également intéressant de pouvoir qualifier les erreurs tout au long du parcours d’exécution.

Pour cela nous allons rajouter une notion de chaîne d’erreurs.

Celle-ci nous permettra au besoin de pouvoir parcourir les différentes erreurs ayant conduits à l’arrêt de l’exécution du programme.

enum MyError {
    Error {
        backtrace: Backtrace,
        details: String,
        source: Box<Option<MyError>>,
    },
    Failure,
}

Notre chaîne d’erreurs sera contenu dans le champ source.

Expliquons son type.

  • Option<MyError> : signifie la possibilité d’avoir un maillon inférieur dans notre chaîne d’erreurs
  • Box<Option<MyError>> : permet de rendre le champ représentable en mémoire, sans cela source aurait une taille infinie.

Nous rajoutons alors à notre macro raise, une autre possibilitée d’être invoquée.

macro_rules! raise {
    ($details:expr) => {
        Err(MyError::Error {
            backtrace: Backtrace::new(),
            details: $details.to_string(),
            source: Box::new(None),
        })
    };
    ($details:expr, $source:expr) => {
        Err(MyError::Error {
            backtrace: Backtrace::new(),
            details: $details.to_string(),
            source: Box::new(Some($source)),
        })
    };
}

Celle-ci prendra en deuxième paramètre une instance de MyError.

Que l’on peut utiliser ainsi:

fn below_deep() -> Result<(), MyError> {
    raise!("occurs into below deep function")
}

fn middle_deep() -> Result<(), MyError> {
    let result = below_deep();
    if let Err(error) = result {
        return raise!("was in middle", error);
    }
    Ok(())
}

fn deep() -> Result<(), MyError> {
    middle_deep()?;
    raise!("occurs into deep function")
}

Dans la méthode middle_deep, au lieu de propager l’erreur directement, nous allons l’aggrémenter d’informations supplémentaires.

Avant de retourner une nouvelle erreur possédant comme source, l’erreur déclenchée par la méthode below_deep.

let result = below_deep();
if let Err(error) = result {
    return raise!("was in middle", error);
}

Nous allons également devoir revoir la manière dont nous affichons nos erreurs.

impl MyError {
    fn format(&self) -> (Vec<String>, Backtrace) {
        let mut outer_details = vec![];
        let outer_backtrace = match self {
            MyError::Error {
                details,
                backtrace,
                source,
            } => {
                let mut backtrace = backtrace.clone();
                outer_details.push(details.to_string());

                if let Some(source) = source.deref() {
                    let (deep_details, result_backtrace) = source.format();
                    backtrace = result_backtrace;
                    outer_details.extend(deep_details.into_iter());
                }
                backtrace
            }
            MyError::Failure => unreachable!(),
        };
        (outer_details, outer_backtrace)
    }
}

L’algorithme est simple: on accumule les détails en traversant les différents maillons de la chaîne d’erreurs et l’on récupère la stacktrace la plus longue et donc celle du maillon le plus profond.

Ce qui nous permet ensuite d’implémenter le trait Debug

impl Debug for MyError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        let (raw_details, backtrace) = self.format();

        let details = raw_details.join("\n\tError: ");
        write!(f, "Error: {details} \t\n {backtrace:?}")
    }
}

Puis de l’afficher dans notre main

fn main() {

    let val = match process(old_val) {
        Ok(val) => {
            println!("Succès");
            println!("val : {val}");
        }
        Err(error @ MyError::Error { .. }) => {
            println!("{error:?}");
        }
        Err(MyError::Failure) => panic!("Crash"),
    };
}

Cela nous donne:

Error: was in middle
	Error: occurs into below deep function
    
   5: error_reporting::below_deep
             at src\main.rs:66
   6: error_reporting::middle_deep
             at src\main.rs:70
   7: error_reporting::deep
             at src\main.rs:78
   8: error_reporting::process
             at src\main.rs:83
   9: error_reporting::main
             at src\main.rs:91

Vu que cette partie est un peu longue, je vous donne le code complet:

Code Rust
use backtrace::Backtrace;
use std::fmt::{Debug, Formatter};
use std::ops::Deref;

enum MyError {
    Error {
        backtrace: Backtrace,
        details: String,
        source: Box<Option<MyError>>,
    },
    Failure,
}

impl MyError {
    fn format(&self) -> (Vec<String>, Backtrace) {
        let mut outer_details = vec![];
        let outer_backtrace = match self {
            MyError::Error {
                details,
                backtrace,
                source,
            } => {
                let mut backtrace = backtrace.clone();
                outer_details.push(details.to_string());

                if let Some(source) = source.deref() {
                    let (deep_details, result_backtrace) = source.format();
                    backtrace = result_backtrace;
                    outer_details.extend(deep_details.into_iter());
                }
                backtrace
            }
            MyError::Failure => unreachable!(),
        };
        (outer_details, outer_backtrace)
    }
}

impl Debug for MyError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        let (raw_details, backtrace) = self.format();

        let details = raw_details.join("\n\tError: ");
        write!(f, "Error: {details} \t\n {backtrace:?}")
    }
}

macro_rules! raise {
    ($details:expr) => {
        Err(MyError::Error {
            backtrace: Backtrace::new(),
            details: $details.to_string(),
            source: Box::new(None),
        })
    };
    ($details:expr, $source:expr) => {
        Err(MyError::Error {
            backtrace: Backtrace::new(),
            details: $details.to_string(),
            source: Box::new(Some($source)),
        })
    };
}

fn below_deep() -> Result<(), MyError> {
    raise!("occurs into below deep function")
}

fn middle_deep() -> Result<(), MyError> {
    let result = below_deep();
    if let Err(error) = result {
        return raise!("was in middle", error);
    }
    Ok(())
}

fn deep() -> Result<(), MyError> {
    middle_deep()?;
    raise!("occurs into deep function")
}

fn process(input: u8) -> Result<u8, MyError> {
    deep()?;
    println!("After deep");
    Ok(input + 1)
}

fn main() {

    let val = match process(old_val) {
        Ok(val) => {
            println!("Succès");
            println!("val : {val}");
        }
        Err(error @ MyError::Error { .. }) => {
            println!("{error:?}");
            old_val
        }
        Err(MyError::Failure) => panic!("Crash"),
    };
}

À partir de ce moment on peut commencer à nous amuser avec le système.

Par exemple effectuer le code

fn open_registry(registry_name: String) -> Result<(), MyError> {
    raise!(format!("Unable to open registry \"{registry_name}\""))
}

fn auth(id: u8) -> Result<(), MyError> {
    let result = open_registry(format!("registry_{id}"));
    if let Err(error) = result {
        return raise!(format!("Unable to open registry for user {id}"), error);
    }
    Ok(())
}

fn run() -> Result<(), MyError> {
    auth(42)?;
    raise!("occurs into deep function")
}

fn process(input: u8) -> Result<u8, MyError> {
    run()?;
    Ok(input + 1)
}

Ce qui nous donne comme chaîne d’erreur ceci:

Error: Unable to open registry for user 42
	Error: Unable to open registry "registry_42"

Ces contextes nous permettent de mieux comprendre les erreurs, et ainsi les debugger plus facilement.

Eyre est là pour nous faciliter la vie

Eyre est une crate qui est conçu pour réaliser la totalité des actions que nous avons implémentées plus haut.

La crate présente plusieurs éléments qui permettent de simplifier la vie au développeur.

L’objet Result de eyre

Le premier élément de la lib eyre est un objet eyre::Result<T, eyre::Report>.

Pour bénéficier de l’error reporting de eyre, vous devez remplacer vos Result par des eyre::Result.

Par exemple, si vous aviez une fonction qui retournait une std::result::Result<(), CustomError>

fn my_function() -> std::result::Result<(), CustomError> {...}

Vous allez devoir la remplacer par

fn my_function() -> eyre::Result<()> {...}

Le Report de eyre

Le eyre::Report est l’alternative d’erreur de eyre::Result. Son rôle est d’accumuler la chaîne d’erreur et de fournir le backtrace de la root_cause de l’erreur.

Générer un Report

Afin de faciliter la création d’un Report, la crate eyre fournit une macro eyre! permettant entre autre de générer un Report qui constituera le premier maillon de notre chaîne d’erreurs.

Cette macro est assez souple, elle peut prendre en entré:

  • soit une chaine de caractères
  • soit une structure ou une énumération qui implémente les traits Debug et Display

Pour une chaine de caractères cela donnerait:

fn my_function() -> eyre::Result<()> {
    Err(eyre!("Cette fonction va échouer"))
}

De même avec la structure ou l’énumération implémentant Debug et Display.

#[derive(Debug)]
enum CustomError {
    One,
}

impl Display for CustomError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}

fn my_function() -> Result<()> {
    Err(eyre!(CustomError::One))
}

Wrapper une Erreur dans un Report

Une autre composante très importante de eyre est sa capacité à venir se greffer à n’importe quelle erreur existante (il peut y avoir des exceptions cependant).

Et venir qualifier des erreurs.

Ceci est réalisé au moyen d’une méthode wrap_err.

Par exemple:

fn my_function(path: &str) -> Result<()> {
    let file = File::open(path).wrap_err("Unable to open file {path}")?;
    Ok(())
}

On remarque le Report peut être manipulé comme n’importe quel Result et donc à la capactité d’être propagé au besoin.

Si l’on tente d’afficher le Report

fn my_function(path: &str) -> Result<()> {
    let report = File::open(path)
        .wrap_err(format!("Unable to open file {path}"))
        .unwrap_err();

    println!("{report:?}");

    Ok(())
}

voici ce que l’on obtient:

Unable to open file /tmp

Caused by:
    Le fichier spécifié est introuvable. (os error 2)

Location:
    src\main.rs:18:10

Stack backtrace:
...

Nous avons bien l’erreur primordiale venant de l’OS, et au-dessus le contexte que l’on a utilisé pour wrapper l’erreur système.

Exemple complet

Si l’on prend un exemple complet cela nous donne quelque chose comme ceci.

Code Rust
use eyre::{eyre, Result, WrapErr};
use std::fs::File;

fn open_ledger_internal(path: &str) -> Result<()> {
    File::open(path).wrap_err(format!("Unable to open file {path}"))?;
    Ok(())
}

fn open_ledger(ledger_id: u32) -> Result<()> {
    open_ledger_internal(&format!("/tmp/registry_{ledger_id}"))
        .wrap_err(eyre!("Unable to open ledger #{ledger_id}"))?;
    Ok(())
}

fn open_registry(registry_name: String) -> Result<()> {
    open_ledger(42666).wrap_err(eyre!("Unable to open registry : {registry_name}"))
}

fn auth(id: u8) -> Result<()> {
    open_registry(format!("registry_{id}"))
        .wrap_err(eyre!("Unable to open registry for user {id}"))?;

    Ok(())
}

fn run(id: u8) -> Result<()> {
    auth(id).wrap_err(format!("Trying to authenticate user #{id}"))?;
    Ok(())
}

fn process(user_id: u8) -> Result<()> {
    run(user_id).wrap_err(format!("Running with user #{user_id}"))?;
    Ok(())
}

fn main() {
    if let Err(report) = process(42) {
        println!("{report:?}")
    }
}

Qui se matérialise dans la console, lorsque l’on affiche le Report final en ceci:

Running with user #42

Caused by:
   0: Trying to authenticate user #42
   1: Unable to open registry for user 42
   2: Unable to open registry : registry_42
   3: Unable to open ledger #42666
   4: Unable to open file /tmp/registry_42666
   5: Le chemin d’accès spécifié est introuvable. (os error 3)

Location:
    src\main.rs:5:22

Stack backtrace:

  10: error_reporting::open_ledger_internal
             at .\src\main.rs:5
  11: error_reporting::open_ledger
             at .\src\main.rs:11
  12: error_reporting::open_registry
             at .\src\main.rs:17
  13: error_reporting::auth
             at .\src\main.rs:21
  14: error_reporting::run
             at .\src\main.rs:28
  15: error_reporting::process
             at .\src\main.rs:33
  16: error_reporting::main
             at .\src\main.rs:38

Cette stacktrace ainsi que les détails nous permettent d’avoir une vue complète de l’exécution de notre programme.

Information

Bien sûr, ici nous avons wrappé d’un contexte tous les Result des différents retours, mais nous aurions très bien pu juste propager l’erreur.

Si l’on modifie dans le code ci-dessus la méthode auth pour retirer le contexte.

fn auth(id: u8) -> Result<()> {
    open_registry(format!("registry_{id}")?;

    Ok(())
}

La chaîne de contextes d’erreurs devient:

Running with user #42

Caused by:
   0: Trying to authenticate user #42
   1: Unable to open registry : registry_42
   2: Unable to open ledger #42666
   3: Unable to open file /tmp/registry_42666
   4: Le chemin d’accès spécifié est introuvable. (os error 3)

Ainsi nul besoin, de vouloir absolument wrapper tous ses Report. La crate est suffisamment souple pour nous permettre de rajouter du contexte uniquement là où cela est nécessaire.

Gestion de l’affichage des Reports

un Report possède plusieurs façon de s’afficher:

Debug

S’affiche avec

println!("{report:?}")

Donne la dernière erreur connue, les details de chaque erreur de la chaîne ainsi que la stacktrace conduisant à l’erreur.

Running with user #42

Caused by:
   0: Trying to authenticate user #42
   1: Unable to open registry for user 42
   2: Unable to open registry : registry_42
   3: Unable to open ledger #42666
   4: Unable to open file /tmp/registry_42666
   5: Le chemin d’accès spécifié est introuvable. (os error 3)

Location:
    src\main.rs:5:22

Stack backtrace:
...

Display

S’affiche avec:

println!("{report}")

Donne seulement la dernière erreur connu et cache la raison primordiale.

Running with user #42

Display alternatif

S’affiche avec:

println!("{report:#}")

Donne toutes les raisons de la chaîne d’erreurs sur une seule ligne, cache la stacktrace.

Running with user #42: Trying to authenticate user #42: Unable to open registry : registry_42: Unable to open ledger #42666: Unable to open file /tmp/registry_42666: Le 
chemin d’accès spécifié est introuvable. (os error 3)

Utiliser eyre dans un exemple concret

Dans un précédent article nous avions uilisé un certain nombre de lib pour à la fois effectuer des requêtes sur le web et désérialiser des données.

Ces bibliothèques possèdent des erreurs spécifiques qui sont incompatibles entre-elles.

Ce qui rend compliquée la gestion immédiate des erreurs qui doivent être mappé vers une Erreur commune.

À l’époque nous avions utilisé la crate thiserror pour réaliser cette conversion.

use thiserror::Error;

#[derive(Error, Debug)]
enum CustomError {
    #[error("sqlite errors {0:?}")]
    Sqlite(sqlite::Error),
    #[error("reqwest errors")]
    Reqwest(reqwest::Error),
    #[error("json errors")]
    Json(serde_json::Error),
}

impl From<sqlite::Error> for CustomError {
    fn from(err: sqlite::Error) -> Self {
        CustomError::Sqlite(err)
    }
}

impl From<serde_json::Error> for CustomError {
    fn from(err: serde_json::Error) -> Self {
        CustomError::Json(err)
    }
}

impl From<reqwest::Error> for CustomError {
    fn from(err: reqwest::Error) -> Self {
        CustomError::Reqwest(err)
    }
}

Nous allons réécrire la même gestion d’erreurs mais en gérant cette fois-ci nos erreur dans un eyre::Report.

Pour plus de clarté je ne n'implémenterai pas la partie sqlite

La fonction qui nous intéresse est celle ci:

#[tracing::instrument]
async fn fetch_user(id: u32) -> Result<User> {
    let url = format!("https://jsonplaceholder.typicode.com/users/{}", id);

    let response = reqwest::get(url).await?;
    let response_string = response.text().await?;
    let user = serde_json::from_str(response_string.as_str())?;

    Ok(user)
}

Elle possède à la fois des erreurs de type reqwest et serde_json, grâce à la bibliothèque thiserror et les impl From<Error> cela est plus ou moins transparent.

Mais, cela représente tout de même un certain volume de code de boilerplate.

Voyons comment réécrire cela grâce à eyre.

use eyre::{eyre, Result, WrapErr};
use serde::Deserialize;

#[derive(Deserialize)]
struct User {
    id: u32,
    name: String,
    email: String,
    phone: String,
    website: String,
}

async fn fetch_user(url: &str) -> Result<User> {
    let response = reqwest::get(url).await?;

    let response_string = response.text().await?;
    let user = serde_json::from_str(response_string.as_str())?;

    Ok(user)
}

#[tokio::main]
async fn main() {
    let url = "https://jsonplaceholder.typicode.com/users/1";
    if let Err(report) = fetch_user(url).await {
        println!("{report:?}")
    }
}

Je vous fournis également le Cargo.toml

[dependencies]
eyre = "0.6.8"
reqwest = { version = "0.11.11", features = ["json"] }
serde = { version = "1.0.140", features = ["derive"] }
serde_json = "1.0.82"
thiserror = "1.0.31"
tokio = {version = "1.20.1", features = ["rt-multi-thread", "macros"]}

C’est plus simple non ? 🤩

On essaie la gestion d’erreur

Pas d’erreur

Pour une url=https://jsonplaceholder.typicode.com/users/1: pas de soucis.

Reqwest

Pour une url=https://jsonplaceholder.typicode.com2/users/1, le report renvoit:

error sending request for url (https://jsonplaceholder.typicode.com2/users/1): error trying to connect: dns error: Hôte inconnu. (os error 11001)

Caused by:
   0: error trying to connect: dns error: Hôte inconnu. (os error 11001)
   1: dns error: Hôte inconnu. (os error 11001)
   2: Hôte inconnu. (os error 11001)

Location:
    src\main.rs:14:20

Stack backtrace:
  10: error_reporting::fetch_user::async_fn$0
             at .\src\main.rs:14
  12: error_reporting::main::async_block$0
             at .\src\main.rs:23
  24: error_reporting::main
             at .\src\main.rs:23

Le sytème tente de réaliser un call HTTP sur le domaine ”jsonplaceholder.typicode.com2”, mais celui-ci n’existe pas.

Json

Pour une url=https://jsonplaceholder.typicode.com/users/22, le report renvoit:

missing field `id` at line 1 column 2

Location:
    src\main.rs:15:16

Stack backtrace:
  10: error_reporting::fetch_user::async_fn$0
             at .\src\main.rs:15
  12: error_reporting::main::async_block$0
             at .\src\main.rs:23
  24: error_reporting::main
             at .\src\main.rs:23

Au-delà de 10, l’API renvoit un json vide. La phase de désérialisation échoue car elle n’arrive pas à trouver le champ id.

Enrichir les erreurs

Cette erreur de désérialisation n’est pas claire, essayons de rajouter un peu plus de contexte.

async fn fetch_user(url: &str) -> Result<User> {
    let response = reqwest::get(url).await?;

    let response_string = response.text().await?;
    let user = serde_json::from_str(response_string.as_str())
        .wrap_err(format!("Error while trying to deserialize user from {url}"))?;

    Ok(user)
}
Error while trying to deserialize user from https://jsonplaceholder.typicode.com/users/22

Caused by:
    missing field `id` at line 1 column 2

Et bien, voilà! C’est beaucoup mieux! 😁

Le debugging sera bien plus simple ainsi.

Et nous nous sommes débarassé de beaucoup de code qui venait parasiter notre compréhension des choses.

Nous nous sommes également épargné un crate supplémentaire.

Utiliser eyre dans le contexte d’une lib

La documentation officielle prévient qu’il n’est pas recommendé d’exposer directement un eyre::Result comme sortie d’erreur publique de votre lib.

La créatrice de eyre en a fait une réponse dans un thread d’issue github.

Pour simuler cette exposition d’erreurs par une lib externe, je vous propose d’utiliser ce projet que nous allons détailler ensemble.

Le projet est composé d’un workspace contenant un sous projet user, ce sous-projet sera notre lib externe exposant une API d’erreur spécifique.

Le main.rs principal est plutôt simple, il déclare le réacteur asynchrone de tokio et affiche soit l’utilisateur si trouvé, soit l’erreur venant de la lib user si ce n’est pas le cas.

#[tokio::main]
async fn main() {
    let url = "https://jsonplaceholder.typicode.com/users/2";

    match user::run(url).await {
        Ok(user) => {
            println!("{user:?}");
        }
        Err(error) => {
            eprintln!("{error}")
        }
    }
}

Dans la lib user, la méthode fetch_user ne bouge pas.

Enfin on introduit dans lib user une méthode run dont son rôle est de transformer le Report de fetch_user en une UserError.

use thiserror::Error;

#[derive(Debug, Error)]
pub enum UserError {
    #[error("Unable to fetch url: {0}")]
    Fetch(String),
    #[error("Unable to unmarshal user data: {0}")]
    Unmarshal(String),
    #[error("Unknown error")]
    Unknown,
}

On réutilise ici la crate thiserror pour nous simplifier la création des erreurs, cette crate nous permettant dimplémenté le trait Display.

On va ici introduire une fonctionnalité essentielle de eyre: le déréférencement !

L’idée est de récupérer l’erreur qui a été wrap par le Report.

Imaginons la situation suivante, si l’on wrap une erreur venant d’un File::open, nous allons obtenir un Report contenant une io::Error.

Pour récupérer cette io::Error, nous allons devoir la déréférencer du Report.


let result : Result<File, Report> = File::open("\tmp\not\exists").wrap_err("This file not exists");

if let Err(report) = result {
    if let Some(io_error: &io::Error) = report.downcast_ref::<io::Error>() {
        // ...
    }
}

Ici le plus important à comprendre c’est l’appel à report.downcast_ref::<E>(), E étant le type vers lequel l’on souhaite caster le Report.

Ce qui fait qu’à partir de ce moment là on est capable de savoir le type exact de l’erreur qui vient d’être levée.

La deuxième capacité de downcast_ref, est de pouvoir également extraire le contexte du Report.

if let Err(report) = result {
    if let Some(io_error: &io::Error) = report.downcast_ref::<io::Error>() {
        if let Some(details: &&str) = report.downcast_ref::<&str>() {
            // details == "This file not exists"
        }
    }
}

Attention

Si le contexte provient d’un format! alors son type est String et non &str.

Il faut donc modifier le downcast en conséquence!

let result : Result<File, Report> = File::open("\tmp\not\exists")
    .wrap_err(format!("File {} not exists", "\tmp\not\exists"));

if let Err(report) = result {
    if let Some(io_error: &io::Error) = report.downcast_ref::<io::Error>() {
        if let Some(details: &String) = report.downcast_ref::<String>() {
            // details == "This file not exists"
        }
    }
}

Revenons maintenant à notre lib user.

Notre fonction run, prend en paramètre une URL et effectue un fetch_user.

S’il y a erreur, elle transforme le Report en une UserError qui peut ensuite être présentée publiquement.

pub async fn run(url: &str) -> Result<User, UserError> {
    match fetch_user(url).await {
        Ok(user) => Ok(user),
        Err(report) => {

            // Gestion d'une erreur de fetch
            if let Some(reqwest_error) = report.downcast_ref::<reqwest::Error>() {
                return Err(UserError::Fetch(reqwest_error.to_string()));
            }

            // Gestion d'une erreur de désérialisation
            if let Some(serde_error) = report.downcast_ref::<serde_json::Error>() {
                // Si du contexte est accroché à l'erreur on le récupère
                if let Some(details) = report.downcast_ref::<String>() {
                    return Err(UserError::Unmarshal(details.to_string()));
                }
                // Sinon on renvoie l'erreur brute
                return Err(UserError::Unmarshal(serde_error.to_string()));
            }
            // On gère aussi le cas d'une erreur inconnue
            Err(UserError::Unknown)
        }
    }
}

Faisons quelques essais.

url=”https://jsonplaceholder.typicode.com/users/2”

User { id: 2, name: “Ervin Howell”, email: “Shanna@melissa.tv”, phone: “010-692-6593 x09125”, website: “anastasia.net” }

url=”https://jsonplaceholder.typicode.com/users/22”

Unable to unmarshal user data: Error while trying to deserialize user from https://jsonplaceholder.typicode.com/users/22

url=”https://jsonplaceholder.typicode.com2/users/2”

Unable to fetch url: error sending request for url (https://jsonplaceholder.typicode.com2/users/): error trying to connect: dns error: Hôte inconnu. (os error 11001)

Conclusion

Et Voilà !

Comme toujours, il y aurait encore tant de chose à dire, mais nous allons nous arrêter là pour aujourd’hui.

J’espère que la découverte de la bibliothèque eyre vous a plu, et que la restropective de la gestion d’erreur aussi! 😁

Je vous souhaite une bonne journée et merci de m’avoir lu. ❤️

avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.