Pular para o conteúdo

Tratamento de erros em Rust com anyhow e thiserror

Nas páginas de Result e Erros personalizados, você escreveu tipos de erro à mão: um enum, um impl Display, talvez um impl From para o ? funcionar. Esse é o jeito certo de aprender. Só que, em um projeto de verdade, você acaba escrevendo o mesmo código repetitivo de novo e de novo.

Dois crates pequenos, do mesmo autor, eliminam esse código repetitivo. Eles resolvem dois problemas diferentes:

  • thiserror ajuda você a definir tipos de erro. Você continua escrevendo o enum, mas um #[derive] escreve Display, Error e From por você.
  • anyhow ajuda você a repassar erros. Ele oferece um único tipo de erro que guarda qualquer erro, além de jeitos fáceis de acrescentar uma mensagem como “ao carregar o arquivo de configuração”.

A escolha depende de quem vai tratar o erro.

Uma biblioteca é um código que outras pessoas chamam. Quem chama pode querer reagir de um jeito diferente a cada falha: tentar de novo em um timeout, mostrar um erro no formulário quando a entrada é inválida. Por isso, uma biblioteca deve retornar um enum preciso, em que quem chama possa fazer match. Isso é o thiserror.

Uma aplicação (o seu main.rs) normalmente é a última parada. A maioria dos erros vira uma mensagem para uma pessoa e, talvez, um código de saída. Você raramente faz match neles; o que você quer é repassá-los facilmente com ? e que sejam fáceis de ler. Isso é o anyhow.

Uma boa regra prática: thiserror para código que outras pessoas chamam, anyhow para o topo do seu programa. Muitos projetos usam os dois, como você vai ver no fim desta página.

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

Ainda não conhece o Cargo.toml? Veja Cargo e crates. Os dois crates estão disponíveis no Rust Playground, então todos os botões “Experimente você mesmo” desta página funcionam.

Coloque #[derive(Debug, Error)] no seu enum e dê a cada variante uma mensagem #[error("...")]. Essa mensagem vira o texto de Display da variante. Dentro dela, {0} significa o primeiro campo da tupla e {balance} significa um campo com nome:

use thiserror::Error;
#[derive(Debug, Error)]
enum BankError {
#[error("conta `{0}` não encontrada")]
NotFound(String),
#[error("saldo insuficiente: o saldo é {balance}, tentou retirar {amount}")]
InsufficientFunds { balance: u32, amount: u32 },
#[error("o valor precisa ser maior que 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]);
}

Saída:

conta `alice` não encontrada
saldo insuficiente: o saldo é 50, tentou retirar 80
o valor precisa ser maior que zero
InsufficientFunds { balance: 50, amount: 80 }
▶ Experimente você mesmo

É exatamente o enum da página de Erros personalizados, mas o bloco impl Display inteiro sumiu. Nada fica escondido em tempo de execução: o thiserror só escreve o código que você teria escrito à mão. Quem chama vê um enum comum.

Erros muitas vezes têm uma causa: “a porta é inválida” porque “invalid digit found in string”. O trait padrão Error tem um método .source() para isso, e o thiserror o preenche para você.

O #[from] faz duas coisas. Ele marca o campo como a origem (source) e escreve um impl From, para que o ? converta o erro interno na sua variante automaticamente:

use std::error::Error; // traz o método .source() para o escopo
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
enum ConfigError {
#[error("falta `=` na linha {0:?}")]
NoEquals(String),
#[error("a porta não é um número válido")]
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 vira ConfigError::BadPort
Ok(port)
}
fn main() {
for line in ["porta = 8080", "porta 8080", "porta = oitenta"] {
match parse_port(line) {
Ok(port) => println!("ok: {port}"),
Err(e) => {
println!("erro: {e}");
if let Some(cause) = e.source() {
println!(" causado por: {cause}");
}
}
}
}
}

Saída:

ok: 8080
erro: falta `=` na linha "porta 8080"
erro: a porta não é um número válido
causado por: invalid digit found in string
▶ Experimente você mesmo

A mensagem “invalid digit found in string” fica em inglês porque é o próprio Rust que a gera (é o Display de ParseIntError).

Às vezes você quer uma origem, mas não um From automático, porque precisa acrescentar dados extras, como o número da linha. Nesse caso, use #[source] no campo e monte o erro você mesmo com .map_err(). (Um campo chamado simplesmente source é tratado como a origem automaticamente.) O thiserror também funciona com structs, não só com enums:

use std::error::Error;
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
#[error("a linha {line} não contém um número")]
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\nvinte\n30").unwrap_err();
println!("{err}");
println!("causado por: {}", err.source().unwrap());
}

Saída:

total: 60
a linha 2 não contém um número
causado por: invalid digit found in string
▶ Experimente você mesmo

anyhow::Result<T> é uma forma curta de escrever Result<T, anyhow::Error>. Um anyhow::Error pode guardar qualquer tipo de erro que implemente o trait padrão Error. Assim, o ? funciona com todos eles, mesmo quando uma única função encontra vários tipos de erro diferentes:

use anyhow::Result;
// Dois tipos de erro diferentes: ParseIntError e ParseFloatError.
// anyhow::Result aceita os dois através do `?`.
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"), ("três", "2.50"), ("3", "barato")] {
match total_price(quantity, price) {
Ok(total) => println!("total: {total}"),
Err(e) => println!("erro: {e}"),
}
}
}

Saída:

total: 7.5
erro: invalid digit found in string
erro: invalid float literal
▶ Experimente você mesmo

Sem o anyhow, você precisaria do seu próprio enum com duas variantes, ou de um Box<dyn Error>. O anyhow é como um Box<dyn Error> mais agradável de usar. Mas repare no problema: “invalid digit found in string” não diz qual valor estava errado. É para isso que serve o contexto.

anyhow: acrescentando contexto e imprimindo a cadeia

Seção intitulada “anyhow: acrescentando contexto e imprimindo a cadeia”

Importe o trait Context e todo Result ganha dois métodos novos:

  • .context("mensagem") envolve o erro com uma mensagem fixa.
  • .with_context(|| format!(...)) recebe uma closure (função anônima). Ela só monta a mensagem se realmente houver um erro, então use essa forma quando a mensagem precisar de format!.

Cada camada de contexto acrescenta um elo a uma cadeia de erros (error chain). O jeito como você imprime o erro decide quanto dessa cadeia aparece:

use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> {
let port: u16 = text
.parse()
.with_context(|| format!("`{text}` não é uma porta válida"))?;
Ok(port)
}
fn load_settings(port_text: &str) -> Result<u16> {
let port = parse_port(port_text).context("não foi possível carregar as configurações")?;
Ok(port)
}
fn main() {
let err = load_settings("99999").unwrap_err();
println!("{err}"); // só a mensagem mais externa
println!("{err:#}"); // a cadeia inteira em uma linha
for (i, cause) in err.chain().enumerate() {
println!("{i}: {cause}");
}
}

Saída:

não foi possível carregar as configurações
não foi possível carregar as configurações: `99999` não é uma porta válida: number too large to fit in target type
0: não foi possível carregar as configurações
1: `99999` não é uma porta válida
2: number too large to fit in target type
▶ Experimente você mesmo

O terceiro formato é o {:?}. Ele imprime um relatório de várias linhas, que é o que você vê quando o main retorna um erro:

println!("{err:?}");
não foi possível carregar as configurações
Caused by:
0: `99999` não é uma porta válida
1: number too large to fit in target type

O cabeçalho “Caused by:” (“causado por”) é escrito pelo próprio anyhow e sempre aparece em inglês. Se a variável de ambiente RUST_BACKTRACE=1 estiver definida, o {:?} também acrescenta um backtrace (o rastro da pilha) mostrando onde o erro foi criado. Veja Ambiente e processos.

Um uso típico no dia a dia é a leitura de arquivos, em que a mensagem do sistema operacional (“No such file or directory”) nunca diz qual era o arquivo:

let text = std::fs::read_to_string(path)
.with_context(|| format!("falha ao ler o arquivo de configuração {path}"))?;

Às vezes não existe um erro por trás. Você simplesmente decide que algo está errado. O anyhow tem três macros para isso:

  • bail!("...") retorna um erro na hora. É uma forma curta de return Err(anyhow!("...")).
  • ensure!(condição, "...") retorna um erro se a condição for falsa.
  • anyhow!("...") cria um valor de erro sem retorná-lo.

As três aceitam mensagens no estilo do format!.

use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> {
ensure!(amount > 0, "o valor precisa ser maior que zero");
if amount > balance {
bail!("saldo insuficiente: o saldo é {balance}, tentou retirar {amount}");
}
Ok(balance - amount)
}
fn find_user(id: u32) -> Result<&'static str> {
match id {
1 => Ok("Ferris"),
_ => Err(anyhow!("nenhum usuário com 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!("erro: {e}");
}
}
println!("{}", find_user(1).unwrap());
println!("erro: {}", find_user(7).unwrap_err());
}

Saída:

Some(70)
erro: o valor precisa ser maior que zero
erro: saldo insuficiente: o saldo é 100, tentou retirar 250
Ferris
erro: nenhum usuário com id 7
▶ Experimente você mesmo

O lugar mais comum em que você vai ver o anyhow é este: o main retorna anyhow::Result<()> e você usa ? em todo lugar. Se um erro chegar até o main, o Rust o imprime com {:?} na saída de erro (stderr) e encerra o programa com o código 1:

use anyhow::{Context, Result};
fn main() -> Result<()> {
let text = "quarenta e dois";
let n: i32 = text
.parse()
.with_context(|| format!("não foi possível ler {text:?} como número"))?;
println!("{n}");
Ok(())
}
▶ Experimente você mesmo

Nada é impresso na saída padrão (stdout). Na stderr você vê:

Error: não foi possível ler "quarenta e dois" como número
Caused by:
invalid digit found in string

Este é o formato típico de um projeto de verdade. O módulo inventory faz o papel de uma biblioteca: ele retorna um InventoryError preciso, criado com thiserror. O código da aplicação usa anyhow, acrescenta contexto e só olha dentro do erro onde isso realmente importa. O downcast_ref pergunta a um anyhow::Error “no fundo, você é deste tipo?” e funciona mesmo depois de acrescentado contexto:

// ----- parte "biblioteca": erros precisos com thiserror -----
mod inventory {
use thiserror::Error;
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("item desconhecido `{0}`")]
UnknownItem(String),
#[error("só restam {available}, não dá para retirar {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 != "maçã" {
return Err(InventoryError::UnknownItem(item.to_string()));
}
if wanted > self.apples {
return Err(InventoryError::NotEnough { available: self.apples, wanted });
}
self.apples -= wanted;
Ok(())
}
}
}
// ----- parte "aplicação": anyhow + contexto -----
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!("o pedido #{id} falhou"))?;
println!("pedido #{id} concluído");
Ok(())
}
fn main() {
let mut store = Store { apples: 5 };
let orders = [(1, "maçã", 2), (2, "ameixa", 1), (3, "maçã", 9)];
for (id, item, amount) in orders {
if let Err(e) = process_order(&mut store, id, item, amount) {
println!("erro: {e:#}");
// Ainda dá para olhar dentro e achar o tipo original do thiserror:
if let Some(InventoryError::NotEnough { available, .. }) =
e.downcast_ref::<InventoryError>()
{
println!(" dica: tente pedir {available} ou menos");
}
}
}
println!("maçãs restantes: {}", store.apples);
}

Saída:

pedido #1 concluído
erro: o pedido #2 falhou: item desconhecido `ameixa`
erro: o pedido #3 falhou: só restam 3, não dá para retirar 9
dica: tente pedir 3 ou menos
maçãs restantes: 3
▶ Experimente você mesmo

Esquecer de importar Context. O .context() é um método do trait anyhow::Context, e métodos de trait só funcionam quando o trait está no escopo:

use anyhow::Result;
fn parse(text: &str) -> Result<i32> {
let n: i32 = text.parse().context("não é um número")?;
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("não é um número")?;
| ^^^^^^^
|
= 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;
|

O compilador diz que não existe nenhum método context no Result e sugere importar o trait Context. A correção: use anyhow::{Context, Result};.

Repetir a mensagem da origem na sua própria mensagem. Com #[from], o erro interno já é a origem. Se você também colocar {0} na mensagem, um relatório completo vai imprimi-la duas vezes:

#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("porta inválida: {0}")] // inclui o texto da origem...
BadPort(#[from] ParseIntError), // ...e este campo também é a origem
}

Impresso com {:#} através do anyhow, isso gera porta inválida: invalid digit found in string: invalid digit found in string. A correção: escreva #[error("porta inválida")] e deixe a cadeia mostrar a causa, ou mantenha o {0} e não marque o campo como origem. Escolha um dos dois.

Imprimir com {} e perder a causa. println!("{e}") em um anyhow::Error mostra só a mensagem mais externa, como “não foi possível carregar as configurações”. Quando for imprimir um erro para uma pessoa ler, use {:#} (uma linha) ou {:?} (relatório completo).

Usar anyhow na API pública de uma biblioteca. Se a sua biblioteca retorna anyhow::Result, quem chama não consegue fazer match nos seus erros. Teria que adivinhar o tipo com downcast_ref. Dê às funções públicas um enum feito com thiserror e deixe o anyhow para aplicações, testes e exemplos.

  • O thiserror serve para definir erros: #[derive(Error)] mais uma mensagem #[error("...")] por variante substituem as implementações de Display e Error escritas à mão.
  • O #[from] faz o ? converter um erro interno na sua variante e o registra como origem. O #[source] registra a origem sem a conversão.
  • O anyhow serve para repassar erros: anyhow::Result<T> aceita qualquer erro através do ?.
  • .context() e .with_context() acrescentam uma camada legível à cadeia de erros. Imprima a cadeia com {:#} (uma linha) ou {:?} (relatório).
  • bail! retorna um erro na hora, ensure! retorna um se uma condição for falsa e anyhow! cria um.
  • Combinação comum: thiserror no código de biblioteca, anyhow no main.

Teste o que aprendeu

1. Você está escrevendo uma biblioteca, e quem chama precisa reagir de um jeito a um timeout e de outro a uma entrada inválida. Qual crate se encaixa melhor?

2. O que o #[from] no campo de uma variante faz?

3. Qual formato imprime um anyhow::Error com a cadeia inteira em uma linha?

4. Quando você deve usar .with_context(|| ...) em vez de .context(...)?

5. O que ensure!(amount > 0, "o valor precisa ser positivo") faz quando amount é 0?

Pontuação: 0 / 5