Gérer ses erreurs en Rust

Lecture 13 min. ‱


Table des matiĂšres

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.

Exemple

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:

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.

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:

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

OĂč

  

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.

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é:

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. ❀