Tratamento de erros em Rust com thiserror e anyhow
1. Visão geral
Rust o força a tratar erros explicitamente através de Result<T, E>. Isso é seguro, mas definir manualmente tipos de erro é verboso. Você precisa implementar manualmente Display, Error e From para cada caso.
Este artigo apresenta os dois crates mais populares para resolver isso. thiserror ajuda a definir tipos de erro estruturados. anyhow ajuda a agrupar todos os erros em um único tipo e adicionar contexto. Após ler, você saberá qual crate usar onde, como combiná-los e como testar erros.
2. Problema ao escrever erros manualmente
Suponha que você leia a quantidade de um pedido de um arquivo. Podem ocorrer dois erros: erro de I/O e erro de parse. Sem usar uma biblioteca, você teria que escrever assim:
use std::fmt;
#[derive(Debug)]
pub enum OrderError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl fmt::Display for OrderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OrderError::Io(_) => write!(f, "não conseguiu ler o arquivo do pedido"),
OrderError::Parse(_) => write!(f, "dados do pedido com formato incorreto"),
}
}
}
impl std::error::Error for OrderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
OrderError::Io(e) => Some(e),
OrderError::Parse(e) => Some(e),
}
}
}
impl From<std::io::Error> for OrderError {
fn from(e: std::io::Error) -> Self { OrderError::Io(e) }
}
impl From<std::num::ParseIntError> for OrderError {
fn from(e: std::num::ParseIntError) -> Self { OrderError::Parse(e) }
}
Você precisa de quase 30 linhas de boilerplate para apenas dois variants. Cada vez que adiciona um variant, você precisa editar em quatro lugares. É exatamente isso que thiserror faz por você.
Um atalho alternativo é usar Box<dyn std::error::Error>. Isso é conciso, mas perde informação de tipo e não permite adicionar contexto. anyhow é uma versão melhor desse atalho.
3. Instalação
Adicione as duas dependências no Cargo.toml:
[dependencies]
thiserror = "2"
anyhow = "1"
Esses dois crates são independentes. Uma biblioteca geralmente só precisa de thiserror. Um binário geralmente usa anyhow, às vezes usando ambos.
4. thiserror: tipo de erro estruturado para biblioteca
4.1. Definindo enum de erro
Com thiserror, o código da seção 2 se reduz a:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OrderError {
#[error("não encontrou o pedido #{0}")]
NotFound(u64),
#[error("quantidade inválida: {quantity} (deve ser > 0)")]
InvalidQuantity { quantity: i32 },
#[error("não conseguiu ler o arquivo do pedido")]
Io(#[from] std::io::Error),
#[error("dados do pedido com formato incorreto")]
Parse(#[from] std::num::ParseIntError),
}
Cada parte tem seu próprio papel:
#[derive(Error)]gera a implementação destd::error::Error.#[error("...")]geraDisplay.{0}referencia o primeiro campo do tuple variant.{quantity}referencia o campo nomeado.#[from]geraimpl From<std::io::Error> for OrderError. Graças a isso, o operador?converte erros automaticamente. Campos com#[from]também se tornam automaticamentesource().
Agora escreva uma função usando esse tipo de erro:
use std::fs;
pub fn parse_quantity(raw: &str) -> Result<i32, OrderError> {
let quantity: i32 = raw.trim().parse()?;
if quantity <= 0 {
return Err(OrderError::InvalidQuantity { quantity });
}
Ok(quantity)
}
pub fn load_quantity(path: &str) -> Result<i32, OrderError> {
let content = fs::read_to_string(path)?;
parse_quantity(&content)
}
A função fs::read_to_string retorna io::Error. O operador ? chama From para convertê-lo em OrderError::Io. Da mesma forma, ParseIntError é convertido em OrderError::Parse. O chamador pode fazer match de cada variant para tratá-los separadamente.
4.2. Percorrer a cadeia de causas com #[source]
Às vezes você quer manter o erro original mas não quer conversão automática com From. Neste caso, use #[source] em vez de #[from]:
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("não conseguiu atualizar o estoque do produto {sku}")]
UpdateFailed {
sku: String,
#[source]
cause: std::io::Error,
},
}
Este variant tem um campo adicional sku, então From não pode ser gerado automaticamente. Aqui #[source] apenas declara cause como a causa. Você pode percorrer a cadeia de causas assim:
use std::error::Error as _;
fn print_chain(err: &dyn std::error::Error) {
eprintln!("Erro: {err}");
let mut current = err.source();
while let Some(cause) = current {
eprintln!(" devido a: {cause}");
current = cause.source();
}
}
Ao chamar print_chain com um UpdateFailed contendo o erro “permission denied”, você verá:
Erro: não conseguiu atualizar o estoque do produto SKU-42
devido a: Permission denied (os error 13)
4.3. #[error(transparent)]
transparent passa direto Display e source() para o erro interno. Você usa quando o variant é apenas um wrapper e não adiciona informações:
#[derive(Debug, Error)]
pub enum PaymentError {
#[error("cartão recusado")]
CardDeclined,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
O variant Other funciona como “outro erro”. Erros específicos que o chamador precisa tratar têm seu próprio variant. Todos os outros erros são agrupados em Other.
5. anyhow: um tipo de erro para toda a aplicação
Na camada de aplicação, raramente você faz match de cada tipo de erro. Geralmente você só precisa registrar o erro ou exibi-lo ao usuário com informações suficientes. anyhow::Error atende perfeitamente a essa necessidade.
5.1. anyhow::Result e contexto
use anyhow::{Context, Result};
fn read_port(path: &str) -> Result<u16> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("não conseguiu ler o arquivo de configuração {path}"))?;
let port: u16 = raw
.trim()
.parse()
.context("porta deve ser um inteiro no intervalo 0–65535")?;
Ok(port)
}
fn main() -> Result<()> {
let port = read_port("app.conf")?;
println!("Servidor executando na porta {port}");
Ok(())
}
O código funciona da seguinte forma:
anyhow::Result<T>é um alias paraResult<T, anyhow::Error>.- Qualquer tipo que implemente
std::error::Error + Send + Sync + 'staticpode ser convertido emanyhow::Errorvia?. .context(...)envolve o erro original em uma nova mensagem, mantendo o erro original como a causa..with_context(|| ...)recebe uma closure. A closure só é executada se houver erro, evitando chamadas desnecessárias deformat!no caminho feliz.
Quando main retorna Err porque o arquivo não existe, o programa imprime:
Error: não conseguiu ler o arquivo de configuração app.conf
Caused by:
No such file or directory (os error 2)
Há três maneiras de formatar um anyhow::Error:
{}imprime apenas a mensagem mais externa.{:#}imprime toda a cadeia em uma linha, separada por:.{:?}imprime em múltiplas linhas como acima.
Usar {:#} ao registrar em uma linha é mais conveniente.
Context também funciona com Option. Esta é uma maneira concisa de converter None em um erro significativo:
use anyhow::{Context, Result};
use std::collections::HashMap;
fn find_email(customers: &HashMap<u64, String>, id: u64) -> Result<&String> {
customers.get(&id).with_context(|| format!("não há cliente #{id}"))
}
5.2. bail!, ensure! e anyhow!
Essas três macros ajudam a criar erros ad-hoc sem precisar definir um tipo:
use anyhow::{anyhow, bail, ensure, Result};
fn apply_discount(total: u64, percent: u8) -> Result<u64> {
ensure!(percent <= 100, "percentual de desconto inválido: {percent}");
if total == 0 {
bail!("não pode descontar um pedido de 0 reais");
}
total
.checked_sub(total * percent as u64 / 100)
.ok_or_else(|| anyhow!("overflow ao calcular o desconto"))
}
ensure!(cond, ...)retorna um erro se a condição for falsa.bail!(...)é equivalente areturn Err(anyhow!(...)).anyhow!(...)cria um valoranyhow::Errorsem retornar.
Por exemplo, apply_discount(500_000, 120) retorna o erro percentual de desconto inválido: 120.
5.3. Downcast quando ainda precisa categorizar erros
anyhow::Error ainda mantém o tipo original. Você pode fazer downcast para lidar com um caso especial:
fn handle(err: &anyhow::Error) {
match err.downcast_ref::<OrderError>() {
Some(OrderError::NotFound(id)) => println!("Exibir página 404 para o pedido #{id}"),
Some(other) => println!("Erro de pedido: {other}"),
None => println!("Erro de sistema: {err:#}"),
}
}
downcast_ref ainda funciona quando o erro foi envolvido com .context(...). No entanto, se você faz downcast frequentemente, é um sinal de que deveria usar um enum thiserror em vez de anyhow.
6. Combinando: thiserror na camada inferior, anyhow na camada superior
Uma regra simples e eficaz:
- Biblioteca ou módulo de domínio usa
thiserror. O chamador precisa saber exatamente qual erro ocorreu para decidir a ação. - Binário, CLI, handler na camada mais externa usa
anyhow. Aqui você principalmente relata o erro.
use anyhow::{Context, Result};
fn process_order_file(path: &str) -> Result<()> {
let quantity = load_quantity(path)
.with_context(|| format!("falha ao processar arquivo de pedido {path}"))?;
println!("Pedido com {quantity} produtos");
Ok(())
}
A função load_quantity retorna um OrderError estruturado. A camada superior adiciona contexto sobre o arquivo sendo processado. Ao executar com um arquivo contendo -3, você verá:
Error: falha ao processar arquivo de pedido orders/1001.txt
Caused by:
quantidade inválida: -3 (deve ser > 0)
7. Testando comportamento de erro
Tipos de erro estruturados tornam o teste preciso. Você pode verificar o variant exato, em vez de comparar strings de forma vaga:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn negative_quantity_returns_invalid_quantity() {
let err = parse_quantity("-3").unwrap_err();
assert!(matches!(err, OrderError::InvalidQuantity { quantity: -3 }));
assert_eq!(err.to_string(), "quantidade inválida: -3 (deve ser > 0)");
}
#[test]
fn non_numeric_input_returns_parse_error() {
let err = parse_quantity("abc").unwrap_err();
assert!(matches!(err, OrderError::Parse(_)));
}
#[test]
fn missing_config_keeps_io_error_as_root_cause() {
let err = read_port("/khong/ton/tai.conf").unwrap_err();
assert_eq!(
err.to_string(),
"não conseguiu ler o arquivo de configuração /khong/ton/tai.conf"
);
assert!(err.root_cause().downcast_ref::<std::io::Error>().is_some());
}
}
Execute os testes com o comando:
cargo test
running 3 tests
test tests::negative_quantity_returns_invalid_quantity ... ok
test tests::non_numeric_input_returns_parse_error ... ok
test tests::missing_config_keeps_io_error_as_root_cause ... ok
test result: ok. 3 passed; 0 failed
O terceiro teste mostra que root_cause() vai até o erro mais profundo da cadeia. Assim você confirma que o contexto não perde o erro original.
8. Erros comuns e quando não usar
Observação: Não repita a mensagem do erro original em
#[error]. Se você escrever#[error("io error: {0}")] Io(#[from] io::Error), a mensagem original aparecerá duas vezes ao imprimir a cadeia completa com{:?}ou{:#}. Escolha um dos dois: incorporar{0}sem declarar source, ou declarar source e manter a mensagem concisa.
Observação: Não use
anyhow::Errorna API pública de uma biblioteca. O chamador não conseguirá fazermatchde erros e será forçado a fazer downcast. Exponha um enumthiserrorem vez disso.
Alguns outros erros que você pode encontrar:
- Dois variants com
#[from]do mesmo tipo de origem. Por exemplo, dois variants envolvendoio::Errorgerarão doisimpl Fromduplicados causando erro de compilação. Use#[source]combinado commap_errpara pelo menos um variant. - Abuso de
#[from]. Se todoio::ErrorviraOrderError::Io, você perde informação sobre qual operação falhou. Variants com campo de contexto, comoUpdateFailed { sku, cause }, geralmente são mais úteis. - Esquecer que
anyhow::Errornão implementastd::error::Error. Ele faz deref paradyn Errormas não implementa o trait. Você não pode passá-lo ondeE: std::error::Erroré necessário. Se necessário, envolva-o com um variant#[error(transparent)]. - Chamar
.context(format!(...))A string é formatada mesmo que não haja erro. Use.with_context(|| ...). - Código extremamente sensível a desempenho ou
no_std.anyhow::Erroraloca na heap. Para hot paths ou ambientes embarcados, um enum compacto é melhor.
Se quer backtrace, defina a variável de ambiente RUST_BACKTRACE=1 ao executar. anyhow imprimirá o backtrace junto com o erro.
9. Conclusão
thiserror e anyhow resolvem duas necessidades diferentes. thiserror elimina boilerplate ao definir tipos de erro, adequado para bibliotecas e camada de domínio onde o chamador precisa categorizar erros. anyhow agrupa todos os erros em um tipo, permitindo adicionar contexto incrementalmente, adequado para binários e camada mais externa.
Você deve combinar os dois: defina erros estruturados na base, adicione contexto no topo. Por fim, mantenha as mensagens concisas, não repita o erro original, e escreva testes que verifiquem o variant exato em vez de apenas comparar strings.