Aller au contenu

Gérer les erreurs avec anyhow et thiserror en Rust

Sur les pages Result et Les erreurs personnalisées, vous avez écrit vos types d’erreur à la main : une enum, un impl Display, parfois un impl From pour que ? fonctionne. C’est la bonne façon d’apprendre. Mais dans un vrai projet, on réécrit ce même code répétitif encore et encore.

Deux petites crates (bibliothèques Rust), écrites par le même auteur, suppriment ce code répétitif. Elles règlent deux problèmes différents :

  • thiserror vous aide à définir des types d’erreur. Vous écrivez toujours l’enum, mais un #[derive] écrit Display, Error et From à votre place.
  • anyhow vous aide à faire remonter les erreurs. Elle vous donne un seul type d’erreur capable de contenir n’importe quelle erreur, et des moyens simples d’ajouter un message comme « pendant le chargement du fichier de configuration ».

Le choix dépend de qui traite l’erreur.

Une bibliothèque, c’est du code que d’autres appellent. Ses appelants voudront peut-être réagir différemment à chaque échec : réessayer après un délai dépassé, afficher une erreur de formulaire en cas de saisie invalide. Une bibliothèque doit donc renvoyer une enum précise, sur laquelle les appelants peuvent faire un match. C’est le rôle de thiserror.

Une application (votre main.rs) est en général le dernier arrêt. La plupart des erreurs finissent en message pour un humain, avec peut-être un code de sortie. On fait rarement un match dessus ; on veut pouvoir les faire remonter facilement avec ? et qu’elles soient faciles à lire. C’est le rôle d’anyhow.

Une bonne règle : thiserror pour le code que d’autres appellent, anyhow pour le haut de votre programme. Beaucoup de projets utilisent les deux, comme vous le verrez à la fin de cette page.

Terminal window
cargo add thiserror anyhow
[dependencies]
anyhow = "1"
thiserror = "2"

Vous découvrez Cargo.toml ? Voyez Cargo et les crates. Les deux crates sont disponibles dans le Playground Rust, donc chaque bouton « Essayez par vous-même » de cette page fonctionne.

thiserror : dériver Error et écrire les messages

Section intitulée « thiserror : dériver Error et écrire les messages »

Mettez #[derive(Debug, Error)] sur votre enum, puis donnez à chaque variante un message #[error("...")]. Ce message devient le texte Display de la variante. À l’intérieur, {0} désigne le premier champ du tuple, et {balance} un champ nommé :

use thiserror::Error;
#[derive(Debug, Error)]
enum BankError {
#[error("compte `{0}` introuvable")]
NotFound(String),
#[error("fonds insuffisants : le solde est de {balance}, retrait demandé de {amount}")]
InsufficientFunds { balance: u32, amount: u32 },
#[error("le montant doit être supérieur à zéro")]
ZeroAmount,
}
fn main() {
let errors = vec![
BankError::NotFound(String::from("alice")),
BankError::InsufficientFunds { balance: 50, amount: 80 },
BankError::ZeroAmount,
];
for e in &errors {
println!("{e}");
}
println!("{:?}", errors[1]);
}

Sortie :

compte `alice` introuvable
fonds insuffisants : le solde est de 50, retrait demandé de 80
le montant doit être supérieur à zéro
InsufficientFunds { balance: 50, amount: 80 }
▶ Essayez par vous-même

C’est exactement l’enum de la page Les erreurs personnalisées, mais tout le bloc impl Display a disparu. Rien n’est caché à l’exécution : thiserror écrit seulement le code que vous auriez écrit à la main. Les appelants voient une enum ordinaire.

Les erreurs ont souvent une cause : « le port n’est pas valide » parce que « invalid digit found in string » (un caractère n’est pas un chiffre). Le trait standard Error a une méthode .source() pour ça, et thiserror la remplit pour vous.

#[from] fait deux choses. Il marque le champ comme source, et il écrit un impl From, si bien que ? convertit automatiquement l’erreur interne en votre variante :

use std::error::Error; // rend la méthode .source() disponible
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
enum ConfigError {
#[error("`=` manquant dans la ligne {0:?}")]
NoEquals(String),
#[error("le port n'est pas un nombre valide")]
BadPort(#[from] ParseIntError),
}
fn parse_port(line: &str) -> Result<u16, ConfigError> {
let (_, value) = line
.split_once('=')
.ok_or_else(|| ConfigError::NoEquals(line.to_string()))?;
let port = value.trim().parse::<u16>()?; // ParseIntError devient ConfigError::BadPort
Ok(port)
}
fn main() {
for line in ["port = 8080", "port 8080", "port = quatre-vingts"] {
match parse_port(line) {
Ok(port) => println!("ok : {port}"),
Err(e) => {
println!("erreur : {e}");
if let Some(cause) = e.source() {
println!(" causé par : {cause}");
}
}
}
}
}

Sortie :

ok : 8080
erreur : `=` manquant dans la ligne "port 8080"
erreur : le port n'est pas un nombre valide
causé par : invalid digit found in string
▶ Essayez par vous-même

La dernière ligne vient de la bibliothèque standard de Rust, qui écrit toujours ses messages en anglais.

Parfois, vous voulez une source mais pas de From automatique, parce que vous devez ajouter des données en plus, comme un numéro de ligne. Mettez alors #[source] sur le champ, et construisez l’erreur vous-même avec .map_err(). (Un champ qui s’appelle simplement source est automatiquement traité comme la source.) thiserror fonctionne aussi sur les structs, pas seulement sur les enums :

use std::error::Error;
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
#[error("la ligne {line} ne contient pas de nombre")]
struct LineError {
line: usize,
#[source]
cause: ParseIntError,
}
fn sum_lines(text: &str) -> Result<i32, LineError> {
let mut total = 0;
for (i, row) in text.lines().enumerate() {
let n: i32 = row
.trim()
.parse()
.map_err(|cause| LineError { line: i + 1, cause })?;
total += n;
}
Ok(total)
}
fn main() {
println!("total : {}", sum_lines("10\n20\n30").unwrap());
let err = sum_lines("10\nvingt\n30").unwrap_err();
println!("{err}");
println!("causé par : {}", err.source().unwrap());
}

Sortie :

total : 60
la ligne 2 ne contient pas de nombre
causé par : invalid digit found in string
▶ Essayez par vous-même

anyhow::Result<T> est un raccourci pour Result<T, anyhow::Error>. Une anyhow::Error peut contenir n’importe quel type d’erreur qui implémente le trait standard Error. Donc ? fonctionne avec toutes, même quand une fonction rencontre plusieurs types d’erreur différents :

use anyhow::Result;
// Deux types d'erreur différents : ParseIntError et ParseFloatError.
// anyhow::Result accepte les deux grâce à `?`.
fn total_price(quantity: &str, price: &str) -> Result<f64> {
let quantity: u32 = quantity.trim().parse()?;
let price: f64 = price.trim().parse()?;
Ok(quantity as f64 * price)
}
fn main() {
for (quantity, price) in [("3", "2.50"), ("trois", "2.50"), ("3", "pas cher")] {
match total_price(quantity, price) {
Ok(total) => println!("total : {total}"),
Err(e) => println!("erreur : {e}"),
}
}
}

Sortie :

total : 7.5
erreur : invalid digit found in string
erreur : invalid float literal
▶ Essayez par vous-même

Sans anyhow, il vous faudrait votre propre enum à deux variantes, ou une Box<dyn Error>. anyhow, c’est comme une Box<dyn Error> plus agréable. Mais remarquez le problème : « invalid digit found in string » (chiffre invalide dans la chaîne) ne dit pas quelle valeur posait problème. C’est à ça que sert le contexte.

anyhow : ajouter du contexte et afficher la chaîne

Section intitulée « anyhow : ajouter du contexte et afficher la chaîne »

Importez le trait Context, et chaque Result gagne deux nouvelles méthodes :

  • .context("message") enveloppe l’erreur avec un message fixe.
  • .with_context(|| format!(...)) prend une closure (fonction anonyme). Elle ne construit le message que s’il y a vraiment une erreur, donc utilisez-la quand le message a besoin de format!.

Chaque couche de contexte ajoute un maillon à une chaîne d’erreurs. La façon dont vous affichez l’erreur décide de la part de la chaîne que vous voyez :

use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> {
let port: u16 = text
.parse()
.with_context(|| format!("`{text}` n'est pas un port valide"))?;
Ok(port)
}
fn load_settings(port_text: &str) -> Result<u16> {
let port = parse_port(port_text).context("impossible de charger la configuration")?;
Ok(port)
}
fn main() {
let err = load_settings("99999").unwrap_err();
println!("{err}"); // seulement le message le plus externe
println!("{err:#}"); // toute la chaîne sur une ligne
for (i, cause) in err.chain().enumerate() {
println!("{i} : {cause}");
}
}

Sortie :

impossible de charger la configuration
impossible de charger la configuration: `99999` n'est pas un port valide: number too large to fit in target type
0 : impossible de charger la configuration
1 : `99999` n'est pas un port valide
2 : number too large to fit in target type
▶ Essayez par vous-même

Avec {:#}, anyhow sépare les maillons par : (sans espace avant, à l’anglaise). Le dernier maillon est le message d’origine de Rust (« nombre trop grand pour le type cible ») : 99999 ne tient pas dans un u16.

Le troisième format est {:?}. Il affiche un rapport sur plusieurs lignes, et c’est ce que vous voyez quand main renvoie une erreur :

println!("{err:?}");
impossible de charger la configuration
Caused by:
0: `99999` n'est pas un port valide
1: number too large to fit in target type

Caused by: (« causé par ») est écrit par anyhow, toujours en anglais. Si la variable d’environnement RUST_BACKTRACE=1 est définie, {:?} ajoute aussi un backtrace (la pile d’appels) qui montre où l’erreur a été créée. Voyez Environnement et processus.

Un usage réel typique est la lecture de fichiers, où le message du système d’exploitation (« No such file or directory ») ne dit jamais de quel fichier il s’agit :

let text = std::fs::read_to_string(path)
.with_context(|| format!("impossible de lire le fichier de configuration {path}"))?;

Parfois, il n’y a aucune erreur sous-jacente. C’est vous qui décidez que quelque chose ne va pas. anyhow a trois macros pour ça :

  • bail!("...") renvoie une erreur immédiatement. C’est un raccourci pour return Err(anyhow!("...")).
  • ensure!(condition, "...") renvoie une erreur si la condition est fausse.
  • anyhow!("...") crée une valeur d’erreur sans la renvoyer.

Toutes les trois acceptent des messages dans le style de format!.

use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> {
ensure!(amount > 0, "le montant doit être supérieur à zéro");
if amount > balance {
bail!("fonds insuffisants : le solde est de {balance}, retrait demandé de {amount}");
}
Ok(balance - amount)
}
fn find_user(id: u32) -> Result<&'static str> {
match id {
1 => Ok("Ferris"),
_ => Err(anyhow!("aucun utilisateur avec l'identifiant {id}")),
}
}
fn main() {
println!("{:?}", withdraw(100, 30).ok());
for (balance, amount) in [(100, 0), (100, 250)] {
if let Err(e) = withdraw(balance, amount) {
println!("erreur : {e}");
}
}
println!("{}", find_user(1).unwrap());
println!("erreur : {}", find_user(7).unwrap_err());
}

Sortie :

Some(70)
erreur : le montant doit être supérieur à zéro
erreur : fonds insuffisants : le solde est de 100, retrait demandé de 250
Ferris
erreur : aucun utilisateur avec l'identifiant 7
▶ Essayez par vous-même

C’est là qu’on voit anyhow le plus souvent : main renvoie anyhow::Result<()>, et vous utilisez ? partout. Si une erreur remonte jusqu’à main, Rust l’affiche avec {:?} sur stderr (la sortie d’erreur) et quitte avec le code 1 :

use anyhow::{Context, Result};
fn main() -> Result<()> {
let text = "quarante-deux";
let n: i32 = text
.parse()
.with_context(|| format!("impossible de lire {text:?} comme un nombre"))?;
println!("{n}");
Ok(())
}
▶ Essayez par vous-même

Rien ne s’affiche sur stdout. Sur stderr, vous voyez :

Error: impossible de lire "quarante-deux" comme un nombre
Caused by:
invalid digit found in string

Voici la forme habituelle d’un vrai projet. Le module inventory joue le rôle d’une bibliothèque : il renvoie une InventoryError précise, faite avec thiserror. Le code de l’application utilise anyhow, ajoute du contexte, et ne regarde à l’intérieur de l’erreur que là où ça l’intéresse vraiment. downcast_ref demande à une anyhow::Error « es-tu en réalité de ce type ? », et ça marche même à travers le contexte ajouté :

// ----- partie « bibliothèque » : erreurs précises avec thiserror -----
mod inventory {
use thiserror::Error;
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("article inconnu `{0}`")]
UnknownItem(String),
#[error("il n'en reste que {available}, impossible d'en prendre {wanted}")]
NotEnough { available: u32, wanted: u32 },
}
pub struct Store {
pub apples: u32,
}
impl Store {
pub fn take(&mut self, item: &str, wanted: u32) -> Result<(), InventoryError> {
if item != "pomme" {
return Err(InventoryError::UnknownItem(item.to_string()));
}
if wanted > self.apples {
return Err(InventoryError::NotEnough { available: self.apples, wanted });
}
self.apples -= wanted;
Ok(())
}
}
}
// ----- partie « application » : anyhow + contexte -----
use anyhow::{Context, Result};
use inventory::{InventoryError, Store};
fn process_order(store: &mut Store, id: u32, item: &str, amount: u32) -> Result<()> {
store
.take(item, amount)
.with_context(|| format!("la commande n°{id} a échoué"))?;
println!("commande n°{id} terminée");
Ok(())
}
fn main() {
let mut store = Store { apples: 5 };
let orders = [(1, "pomme", 2), (2, "prune", 1), (3, "pomme", 9)];
for (id, item, amount) in orders {
if let Err(e) = process_order(&mut store, id, item, amount) {
println!("erreur : {e:#}");
// On peut quand même retrouver le type thiserror d'origine :
if let Some(InventoryError::NotEnough { available, .. }) =
e.downcast_ref::<InventoryError>()
{
println!(" conseil : essayez d'en commander {available} ou moins");
}
}
}
println!("pommes restantes : {}", store.apples);
}

Sortie :

commande n°1 terminée
erreur : la commande n°2 a échoué: article inconnu `prune`
erreur : la commande n°3 a échoué: il n'en reste que 3, impossible d'en prendre 9
conseil : essayez d'en commander 3 ou moins
pommes restantes : 3
▶ Essayez par vous-même

Oublier d’importer Context. .context() est une méthode du trait anyhow::Context, et les méthodes d’un trait ne fonctionnent que si ce trait est dans la portée :

use anyhow::Result;
fn parse(text: &str) -> Result<i32> {
let n: i32 = text.parse().context("pas un nombre")?;
Ok(n)
}
error[E0599]: no method named `context` found for enum `Result<T, E>` in the current scope
--> src/main.rs:4:31
|
4 | let n: i32 = text.parse().context("pas un nombre")?;
| ^^^^^^^
|
= help: items from traits can only be used if the trait is in scope
help: trait `Context` which provides `context` is implemented but not in scope; perhaps you want to import it
|
1 + use anyhow::Context;
|

Le compilateur dit qu’il ne trouve pas de méthode context sur Result, et vous suggère lui-même d’importer anyhow::Context. La solution : use anyhow::{Context, Result};.

Répéter le message de la source dans votre propre message. Avec #[from], l’erreur interne est déjà la source. Si vous mettez en plus {0} dans le message, un rapport complet l’affiche deux fois :

#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("port invalide : {0}")] // contient le texte de la source...
BadPort(#[from] ParseIntError), // ...et c'est aussi la source
}

Affiché avec {:#} via anyhow, cela donne port invalide : invalid digit found in string: invalid digit found in string. La solution : soit écrire #[error("port invalide")] et laisser la chaîne montrer la cause, soit garder {0} et ne pas marquer le champ comme source. Choisissez l’un ou l’autre.

Afficher avec {} et perdre la cause. println!("{e}") sur une anyhow::Error n’affiche que le message le plus externe, comme « impossible de charger la configuration ». Quand vous affichez une erreur pour un humain, utilisez {:#} (une ligne) ou {:?} (rapport complet).

Utiliser anyhow dans l’API publique d’une bibliothèque. Si votre bibliothèque renvoie anyhow::Result, ses appelants ne peuvent pas faire de match sur vos erreurs. Ils devraient deviner le type avec downcast_ref. Donnez aux fonctions publiques une enum thiserror, et gardez anyhow pour les applications, les tests et les exemples.

  • thiserror sert à définir des erreurs : #[derive(Error)] plus un message #[error("...")] par variante remplacent vos implémentations de Display et Error écrites à la main.
  • #[from] permet à ? de convertir une erreur interne en votre variante, et l’enregistre comme source. #[source] enregistre la source sans la conversion.
  • anyhow sert à faire remonter les erreurs : anyhow::Result<T> accepte n’importe quelle erreur via ?.
  • .context() et .with_context() ajoutent une couche lisible par un humain à la chaîne d’erreurs. Affichez la chaîne avec {:#} (une ligne) ou {:?} (rapport).
  • bail! renvoie une erreur tout de suite, ensure! en renvoie une si une condition est fausse, et anyhow! en crée une.
  • Organisation courante : thiserror dans le code de bibliothèque, anyhow dans main.

Petit quiz

1. Vous écrivez une bibliothèque, et ses appelants doivent réagir différemment à un délai dépassé et à une saisie invalide. Quelle crate convient le mieux ?

2. Que fait #[from] sur le champ d'une variante ?

3. Quel format affiche une anyhow::Error avec toute sa chaîne sur une seule ligne ?

4. Quand faut-il utiliser .with_context(|| ...) plutôt que .context(...) ?

5. Que fait ensure!(amount > 0, "le montant doit être positif") quand amount vaut 0 ?

Score : 0 / 5