Gérer ses erreurs en Rust
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
.
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.
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
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
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:
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:
Nous alors utiliser l’objet Rusult
comme retour de notre méthode process
.
Puis traiter l’erreur éventuelle, au travers d’un match
.
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.
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
.
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.
Bien sûr, ce processus peut se réaliser à n’importe quelle profondeur d’exécution.
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.
Au lieu de renvoyer une simple erreur, nous allons également rajouter du contexte sous la forme d’une chaîne de caractères.
Puis, nous pouvons alors afficher cette Erreur avec plus de détails.
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
Pour nous faciliter la vie nous créons un contructeur
Que nous utilisons dans la suite du code
On modifie également la façon d’afficher les erreurs, pour rajouter la notion de backtrace.
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. 😀
Que l’on utilise ainsi:
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.
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’erreursBox<Option<MyError>>
: permet de rendre le champ représentable en mémoire, sans celasource
aurait une taille infinie.
Nous rajoutons alors à notre macro raise
, une autre possibilitée d’être invoquée.
Celle-ci prendra en deuxième paramètre une instance de MyError
.
Que l’on peut utiliser ainsi:
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 = result
Nous allons également devoir revoir la manière dont nous affichons nos erreurs.
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
Puis de l’afficher dans notre main
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;
use ;
use Deref;
À partir de ce moment on peut commencer à nous amuser avec le système.
Par exemple effectuer le code
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>
Vous allez devoir la remplacer par
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
etDisplay
Pour une chaine de caractères cela donnerait:
De même avec la structure ou l’énumération implémentant Debug
et Display
.
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:
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
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 ;
use File;
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.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!
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!
Donne seulement la dernière erreur connu et cache la raison primordiale.
Running with user #42
Display alternatif
S’affiche avec:
println!
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 Error;
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:
async
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 ;
use Deserialize;
async
async
Je vous fournis également le Cargo.toml
[]
= "0.6.8"
= { = "0.11.11", = ["json"] }
= { = "1.0.140", = ["derive"] }
= "1.0.82"
= "1.0.31"
= { = "1.20.1", = ["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
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.
async
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 Error;
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 : = open.wrap_err;
if let Err = result
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 = result
Attention
Si le contexte provient d’un
format!
alors son type estString
et non&str
.Il faut donc modifier le downcast en conséquence!
let result : = open .wrap_err; if let Err = result
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
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. ❤️
Ce travail est sous licence CC BY-NC-SA 4.0.