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:
thiserrorajuda você a definir tipos de erro. Você continua escrevendo o enum, mas um#[derive]escreveDisplay,ErroreFrompor você.anyhowajuda 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”.
Bibliotecas vs aplicações
Seção intitulada “Bibliotecas vs aplicações”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.
Preparando o projeto
Seção intitulada “Preparando o projeto”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.
thiserror: derivar Error e escrever as mensagens
Seção intitulada “thiserror: derivar Error e escrever as mensagens”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 encontradasaldo insuficiente: o saldo é 50, tentou retirar 80o valor precisa ser maior que zeroInsufficientFunds { balance: 50, amount: 80 }É 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.
thiserror: #[from] e #[source]
Seção intitulada “thiserror: #[from] e #[source]”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 escopouse 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: 8080erro: falta `=` na linha "porta 8080"erro: a porta não é um número válido causado por: invalid digit found in stringA 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: 60a linha 2 não contém um númerocausado por: invalid digit found in stringanyhow: um único tipo Result para tudo
Seção intitulada “anyhow: um único tipo Result para tudo”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.5erro: invalid digit found in stringerro: invalid float literalSem 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 deformat!.
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çõesnão foi possível carregar as configurações: `99999` não é uma porta válida: number too large to fit in target type0: não foi possível carregar as configurações1: `99999` não é uma porta válida2: number too large to fit in target typeO 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 typeO 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}"))?;anyhow: bail!, ensure! e anyhow!
Seção intitulada “anyhow: bail!, ensure! e anyhow!”À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 dereturn 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 zeroerro: saldo insuficiente: o saldo é 100, tentou retirar 250Ferriserro: nenhum usuário com id 7Retornando anyhow::Result do main
Seção intitulada “Retornando anyhow::Result do main”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(())}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 stringUsando os dois juntos
Seção intitulada “Usando os dois juntos”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ídoerro: 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 menosmaçãs restantes: 3Erros comuns
Seção intitulada “Erros comuns”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 scopehelp: 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 deDisplayeErrorescritas à 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 eanyhow!cria um.- Combinação comum: thiserror no código de biblioteca, anyhow no
main.
Teste o que aprendeu
Seção intitulada “Teste o que aprendeu”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
Para saber mais
Seção intitulada “Para saber mais”- Documentação do anyhow — docs.rs (em inglês): a API completa, incluindo
Context,chain()e downcasting. - Documentação do thiserror — docs.rs (em inglês): todos os atributos, como
#[error(transparent)]e campos de backtrace. - O trait Context — anyhow (em inglês): como
contextewith_contextfuncionam, com exemplos. - Error Handling — The Rust Book (em inglês): os fundamentos sobre os quais os dois crates são construídos.
- std::error::Error (em inglês): o trait padrão por trás do
.source()e das cadeias de erros.