Salta ai contenuti

Gestire gli errori in Rust con anyhow e thiserror

Nelle pagine su Result e sugli errori personalizzati hai scritto i tipi di errore a mano: un’enum, un impl Display, magari un impl From per far funzionare ?. È il modo giusto per imparare. Ma in un progetto vero finisci per scrivere lo stesso codice ripetitivo ancora e ancora.

Due piccoli crate, entrambi dello stesso autore, eliminano quel codice ripetitivo. Risolvono due problemi diversi:

  • thiserror ti aiuta a definire i tipi di errore. L’enum la scrivi ancora tu, ma un #[derive] scrive per te Display, Error e From.
  • anyhow ti aiuta a passare gli errori verso l’alto. Ti dà un unico tipo di errore che può contenere qualsiasi errore, più modi semplici per aggiungere un messaggio come «durante il caricamento del file di configurazione».

Quale scegliere dipende da chi gestisce l’errore.

Una libreria è codice che altre persone chiamano. Chi la chiama potrebbe voler reagire in modo diverso a ogni fallimento: riprovare dopo un timeout, mostrare un errore nel modulo quando l’input è sbagliato. Quindi una libreria dovrebbe restituire un’enum precisa su cui chi chiama può fare match. Questo è thiserror.

Un’applicazione (il tuo main.rs) di solito è l’ultima fermata. La maggior parte degli errori finisce come messaggio per una persona, e magari un codice di uscita. Raramente ci fai match; vuoi che siano facili da passare in alto con ? e facili da leggere. Questo è anyhow.

Una buona regola pratica: thiserror per il codice che chiamano gli altri, anyhow per la parte più alta del tuo programma. Molti progetti li usano entrambi, come vedrai alla fine di questa pagina.

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

Non conosci ancora Cargo.toml? Vedi Cargo e crate. Entrambi i crate sono disponibili nel Rust Playground, quindi ogni pulsante «Provalo tu» di questa pagina funziona.

Metti #[derive(Debug, Error)] sulla tua enum, poi dai a ogni variante un messaggio #[error("...")]. Il messaggio diventa il testo Display della variante. Dentro il messaggio, {0} indica il primo campo della tupla e {balance} un campo con nome:

use thiserror::Error;
#[derive(Debug, Error)]
enum BankError {
#[error("conto `{0}` non trovato")]
NotFound(String),
#[error("fondi insufficienti: il saldo è {balance}, hai provato a prelevare {amount}")]
InsufficientFunds { balance: u32, amount: u32 },
#[error("l'importo deve essere maggiore di zero")]
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]);
}

Output:

conto `alice` non trovato
fondi insufficienti: il saldo è 50, hai provato a prelevare 80
l'importo deve essere maggiore di zero
InsufficientFunds { balance: 50, amount: 80 }
▶ Provalo tu

È esattamente l’enum della pagina sugli errori personalizzati, ma l’intero blocco impl Display è sparito. A runtime non c’è niente di nascosto: thiserror scrive solo il codice che avresti scritto tu a mano. Chi chiama vede un’enum normale.

Spesso gli errori hanno una causa: «la porta non è valida» perché «c’è una cifra non valida nella stringa». Il trait standard Error ha un metodo .source() per questo, e thiserror lo compila per te.

#[from] fa due cose. Segna il campo come sorgente (source) e scrive un impl From, così ? converte automaticamente l’errore interno nella tua variante:

use std::error::Error; // porta in scope il metodo .source()
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
enum ConfigError {
#[error("manca `=` nella riga {0:?}")]
NoEquals(String),
#[error("la porta non è un numero valido")]
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 diventa ConfigError::BadPort
Ok(port)
}
fn main() {
for line in ["port = 8080", "port 8080", "port = ottanta"] {
match parse_port(line) {
Ok(port) => println!("ok: {port}"),
Err(e) => {
println!("errore: {e}");
if let Some(cause) = e.source() {
println!(" causato da: {cause}");
}
}
}
}
}

Output:

ok: 8080
errore: manca `=` nella riga "port 8080"
errore: la porta non è un numero valido
causato da: invalid digit found in string
▶ Provalo tu

La causa, invalid digit found in string («cifra non valida nella stringa»), è il messaggio della libreria standard e resta in inglese.

A volte vuoi una sorgente ma non un From automatico, perché devi aggiungere dati in più, come un numero di riga. Allora usa #[source] sul campo e costruisci tu l’errore con .map_err(). (Un campo che si chiama semplicemente source viene trattato automaticamente come sorgente.) thiserror funziona anche sulle struct, non solo sulle enum:

use std::error::Error;
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
#[error("la riga {line} non contiene un numero")]
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!("totale: {}", sum_lines("10\n20\n30").unwrap());
let err = sum_lines("10\nventi\n30").unwrap_err();
println!("{err}");
println!("causato da: {}", err.source().unwrap());
}

Output:

totale: 60
la riga 2 non contiene un numero
causato da: invalid digit found in string
▶ Provalo tu

anyhow::Result<T> è un’abbreviazione di Result<T, anyhow::Error>. Un anyhow::Error può contenere qualsiasi tipo di errore che implementa il trait standard Error. Quindi ? funziona con tutti, anche quando una funzione incontra diversi tipi di errore:

use anyhow::Result;
// Due tipi di errore diversi: ParseIntError e ParseFloatError.
// anyhow::Result li accetta entrambi tramite `?`.
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"), ("tre", "2.50"), ("3", "economico")] {
match total_price(quantity, price) {
Ok(total) => println!("totale: {total}"),
Err(e) => println!("errore: {e}"),
}
}
}

Output:

totale: 7.5
errore: invalid digit found in string
errore: invalid float literal
▶ Provalo tu

Senza anyhow ti servirebbe un’enum tua con due varianti, oppure un Box<dyn Error>. anyhow è come un Box<dyn Error> più comodo. Ma nota il problema: «invalid digit found in string» non dice quale valore era sbagliato. È a questo che serve il contesto.

Importa il trait Context, e ogni Result riceve due metodi nuovi:

  • .context("messaggio") avvolge l’errore con un messaggio fisso.
  • .with_context(|| format!(...)) prende una closure. Costruisce il messaggio solo se c’è davvero un errore, quindi usalo quando il messaggio ha bisogno di format!.

Ogni strato di contesto aggiunge un anello a una catena di errori. Il modo in cui stampi l’errore decide quanta parte della catena vedi:

use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> {
let port: u16 = text
.parse()
.with_context(|| format!("`{text}` non è una porta valida"))?;
Ok(port)
}
fn load_settings(port_text: &str) -> Result<u16> {
let port = parse_port(port_text).context("impossibile caricare le impostazioni")?;
Ok(port)
}
fn main() {
let err = load_settings("99999").unwrap_err();
println!("{err}"); // solo il messaggio più esterno
println!("{err:#}"); // tutta la catena su una riga
for (i, cause) in err.chain().enumerate() {
println!("{i}: {cause}");
}
}

Output:

impossibile caricare le impostazioni
impossibile caricare le impostazioni: `99999` non è una porta valida: number too large to fit in target type
0: impossibile caricare le impostazioni
1: `99999` non è una porta valida
2: number too large to fit in target type
▶ Provalo tu

L’ultimo anello, number too large to fit in target type («numero troppo grande per il tipo di destinazione»), viene dalla libreria standard. Il terzo formato è {:?}. Stampa un resoconto su più righe, ed è quello che vedi quando main restituisce un errore:

println!("{err:?}");
impossibile caricare le impostazioni
Caused by:
0: `99999` non è una porta valida
1: number too large to fit in target type

Caused by: («causato da») è scritto da anyhow, sempre in inglese. Se è impostata la variabile d’ambiente RUST_BACKTRACE=1, {:?} aggiunge anche un backtrace dello stack che mostra dove è stato creato l’errore. Vedi Ambiente e processi.

Un uso tipico nella realtà è la lettura dei file, dove il messaggio del sistema operativo («No such file or directory») non dice mai di quale file si tratta:

let text = std::fs::read_to_string(path)
.with_context(|| format!("impossibile leggere il file di configurazione {path}"))?;

A volte non c’è un errore di fondo. Sei tu a decidere che qualcosa non va. anyhow ha tre macro per questo:

  • bail!("...") restituisce subito un errore. È un’abbreviazione di return Err(anyhow!("...")).
  • ensure!(condizione, "...") restituisce un errore se la condizione è falsa.
  • anyhow!("...") crea un valore di errore senza restituirlo.

Tutte e tre accettano messaggi nello stile di format!.

use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> {
ensure!(amount > 0, "l'importo deve essere maggiore di zero");
if amount > balance {
bail!("fondi insufficienti: il saldo è {balance}, hai provato a prelevare {amount}");
}
Ok(balance - amount)
}
fn find_user(id: u32) -> Result<&'static str> {
match id {
1 => Ok("Ferris"),
_ => Err(anyhow!("nessun utente con id {id}")),
}
}
fn main() {
println!("{:?}", withdraw(100, 30).ok());
for (balance, amount) in [(100, 0), (100, 250)] {
if let Err(e) = withdraw(balance, amount) {
println!("errore: {e}");
}
}
println!("{}", find_user(1).unwrap());
println!("errore: {}", find_user(7).unwrap_err());
}

Output:

Some(70)
errore: l'importo deve essere maggiore di zero
errore: fondi insufficienti: il saldo è 100, hai provato a prelevare 250
Ferris
errore: nessun utente con id 7
▶ Provalo tu

Il posto in cui vedi anyhow più spesso è questo: main restituisce anyhow::Result<()>, e usi ? dappertutto. Se un errore arriva fino a main, Rust lo stampa con {:?} su stderr ed esce con il codice 1:

use anyhow::{Context, Result};
fn main() -> Result<()> {
let text = "quarantadue";
let n: i32 = text
.parse()
.with_context(|| format!("impossibile leggere {text:?} come numero"))?;
println!("{n}");
Ok(())
}
▶ Provalo tu

Su stdout non viene stampato niente. Su stderr vedi:

Error: impossibile leggere "quarantadue" come numero
Caused by:
invalid digit found in string

Ecco la forma tipica di un progetto vero. Il modulo inventory fa la parte della libreria: restituisce un InventoryError preciso, creato con thiserror. Il codice dell’applicazione usa anyhow, aggiunge contesto e guarda dentro l’errore solo dove gli interessa davvero. downcast_ref chiede a un anyhow::Error «sei per caso di questo tipo?» e funziona anche attraverso il contesto aggiunto:

// ----- parte "libreria": errori precisi con thiserror -----
mod inventory {
use thiserror::Error;
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("articolo sconosciuto `{0}`")]
UnknownItem(String),
#[error("ne restano solo {available}, impossibile prenderne {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 != "mela" {
return Err(InventoryError::UnknownItem(item.to_string()));
}
if wanted > self.apples {
return Err(InventoryError::NotEnough { available: self.apples, wanted });
}
self.apples -= wanted;
Ok(())
}
}
}
// ----- parte "applicazione": anyhow + contesto -----
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!("ordine #{id} fallito"))?;
println!("ordine #{id} completato");
Ok(())
}
fn main() {
let mut store = Store { apples: 5 };
let orders = [(1, "mela", 2), (2, "prugna", 1), (3, "mela", 9)];
for (id, item, amount) in orders {
if let Err(e) = process_order(&mut store, id, item, amount) {
println!("errore: {e:#}");
// Possiamo ancora guardare dentro per trovare il tipo thiserror originale:
if let Some(InventoryError::NotEnough { available, .. }) =
e.downcast_ref::<InventoryError>()
{
println!(" suggerimento: prova a ordinarne {available} o meno");
}
}
}
println!("mele rimaste: {}", store.apples);
}

Output:

ordine #1 completato
errore: ordine #2 fallito: articolo sconosciuto `prugna`
errore: ordine #3 fallito: ne restano solo 3, impossibile prenderne 9
suggerimento: prova a ordinarne 3 o meno
mele rimaste: 3
▶ Provalo tu

Dimenticare di importare Context. .context() è un metodo del trait anyhow::Context, e i metodi di un trait funzionano solo quando il trait è in scope:

use anyhow::Result;
fn parse(text: &str) -> Result<i32> {
let n: i32 = text.parse().context("not a number")?;
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("not a number")?;
| ^^^^^^^
|
= 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;
|

Il compilatore dice (in inglese) che non trova nessun metodo context e suggerisce di importare il trait Context. La correzione: use anyhow::{Context, Result};.

Ripetere il messaggio della sorgente nel tuo messaggio. Con #[from], l’errore interno è già la sorgente. Se metti anche {0} nel messaggio, un resoconto completo lo stampa due volte:

#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("porta non valida: {0}")] // contiene il testo della sorgente...
BadPort(#[from] ParseIntError), // ...e anche questa è la sorgente
}

Stampato con {:#} tramite anyhow, dà porta non valida: invalid digit found in string: invalid digit found in string. La correzione: o scrivi #[error("porta non valida")] e lasci che la catena mostri la causa, oppure tieni {0} e non segni il campo come sorgente. Scegline una.

Stampare con {} e perdere la causa. println!("{e}") su un anyhow::Error mostra solo il messaggio più esterno, come «impossibile caricare le impostazioni». Quando stampi un errore per una persona, usa {:#} (una riga) o {:?} (resoconto completo).

Usare anyhow nell’API pubblica di una libreria. Se la tua libreria restituisce anyhow::Result, chi la chiama non può fare match sui tuoi errori. Dovrebbe indovinare il tipo con downcast_ref. Dai alle funzioni pubbliche un’enum thiserror, e tieni anyhow per applicazioni, test ed esempi.

  • thiserror serve a definire gli errori: #[derive(Error)] più un messaggio #[error("...")] per ogni variante sostituisce gli impl Display ed Error scritti a mano.
  • #[from] fa sì che ? converta un errore interno nella tua variante e lo registra come sorgente. #[source] registra la sorgente senza la conversione.
  • anyhow serve a passare gli errori verso l’alto: anyhow::Result<T> accetta qualsiasi errore tramite ?.
  • .context() e .with_context() aggiungono alla catena di errori uno strato leggibile per le persone. Stampa la catena con {:#} (una riga) o {:?} (resoconto).
  • bail! restituisce subito un errore, ensure! ne restituisce uno se una condizione è falsa, e anyhow! ne crea uno.
  • Configurazione comune: thiserror nel codice di libreria, anyhow in main.

Verifica veloce

1. Stai scrivendo una libreria, e chi la chiama deve reagire in modo diverso a un timeout e a un input sbagliato. Quale crate è più adatto?

2. Cosa fa #[from] sul campo di una variante?

3. Quale formato stampa un anyhow::Error con tutta la sua catena su una riga?

4. Quando conviene usare .with_context(|| ...) invece di .context(...)?

5. Cosa fa ensure!(amount > 0, "l'importo deve essere positivo") quando amount vale 0?

Punteggio: 0 / 5