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 de std::error::Error.
  • #[error("...")] gera Display. {0} referencia o primeiro campo do tuple variant. {quantity} referencia o campo nomeado.
  • #[from] gera impl From<std::io::Error> for OrderError. Graças a isso, o operador ? converte erros automaticamente. Campos com #[from] também se tornam automaticamente source().

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 para Result<T, anyhow::Error>.
  • Qualquer tipo que implemente std::error::Error + Send + Sync + 'static pode ser convertido em anyhow::Error via ?.
  • .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 de format! 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 a return Err(anyhow!(...)).
  • anyhow!(...) cria um valor anyhow::Error sem 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::Error na API pública de uma biblioteca. O chamador não conseguirá fazer match de erros e será forçado a fazer downcast. Exponha um enum thiserror em vez disso.

Alguns outros erros que você pode encontrar:

  • Dois variants com #[from] do mesmo tipo de origem. Por exemplo, dois variants envolvendo io::Error gerarão dois impl From duplicados causando erro de compilação. Use #[source] combinado com map_err para pelo menos um variant.
  • Abuso de #[from]. Se todo io::Error vira OrderError::Io, você perde informação sobre qual operação falhou. Variants com campo de contexto, como UpdateFailed { sku, cause }, geralmente são mais úteis.
  • Esquecer que anyhow::Error não implementa std::error::Error. Ele faz deref para dyn Error mas não implementa o trait. Você não pode passá-lo onde E: 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::Error aloca 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.

rusterror-handlingthiserroranyhow