Error Handling Rust: Libs e Boas Práticas | Rust Brasil

Bibliotecas de error handling em Rust: anyhow, thiserror, eyre, color-eyre e miette. Quando usar cada uma e boas práticas.

Introdução

O tratamento de erros é um dos pontos fortes do Rust — o sistema de tipos com Result<T, E> e a ausência de exceções forçam o desenvolvedor a lidar com erros de forma explícita. Porém, implementar tipos de erro customizados “na mão” pode ser trabalhoso.

Três bibliotecas surgiram para resolver esse problema, cada uma com uma filosofia diferente:

  • thiserror — facilita a criação de tipos de erro customizados para bibliotecas, gerando automaticamente implementações de std::error::Error e Display
  • anyhow — fornece um tipo de erro genérico para aplicações, permitindo propagar qualquer erro com contexto adicional
  • miette — estende o tratamento de erros com diagnósticos ricos e formatação visual bonita, ideal para CLIs e ferramentas de desenvolvimento

As três foram criadas por membros proeminentes da comunidade Rust e são amplamente utilizadas em produção.

Tabela Comparativa

Característicathiserroranyhowmiette
AutorDavid TolnayDavid TolnayKat Marchán
Uso principalLibrary cratesApplication cratesCLIs e ferramentas
Tipo de erroCustomizado (enum/struct)anyhow::Error genéricomiette::Report com diagnósticos
Derive macro#[derive(Error)]N/A#[derive(Diagnostic)]
ContextoVia #[from] e #[source].context() e .with_context()Spans, labels, help, URL
Pretty printNãoNão (básico)Sim (formatação rica)
PerformanceZero-cost (compile-time)Mínimo overheadOverhead para diagnósticos
BacktraceVia stdSim (automático)Sim

Dependências no Cargo.toml

[dependencies]
thiserror = "2"          # Para tipos de erro customizados
anyhow = "1"             # Para propagação genérica de erros
miette = { version = "7", features = ["fancy"] }  # Para diagnósticos ricos

thiserror: Erros Customizados para Bibliotecas

O thiserror usa derive macros para gerar implementações de std::error::Error, Display e From:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("Usuário não encontrado: {id}")]
    UsuarioNaoEncontrado { id: u64 },

    #[error("Credenciais inválidas para o usuário '{usuario}'")]
    CredenciaisInvalidas { usuario: String },

    #[error("Erro de banco de dados")]
    Banco(#[from] sqlx::Error),

    #[error("Erro de I/O: {0}")]
    Io(#[from] std::io::Error),

    #[error("Erro de serialização: {0}")]
    Json(#[from] serde_json::Error),

    #[error("Valor inválido: {campo} deve ser {regra}")]
    Validacao { campo: String, regra: String },

    #[error("Erro interno: {0}")]
    Interno(String),
}

Uso em Funções

use thiserror::Error;
use std::fs;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("Arquivo de configuração não encontrado: {caminho}")]
    ArquivoNaoEncontrado { caminho: String },

    #[error("Erro ao ler arquivo: {0}")]
    Io(#[from] std::io::Error),

    #[error("Erro ao fazer parse da configuração: {0}")]
    Parse(#[from] toml::de::Error),

    #[error("Campo obrigatório ausente: '{campo}'")]
    CampoAusente { campo: String },
}

#[derive(serde::Deserialize)]
struct Config {
    host: Option<String>,
    porta: Option<u16>,
}

fn carregar_config(caminho: &str) -> Result<Config, ConfigError> {
    if !std::path::Path::new(caminho).exists() {
        return Err(ConfigError::ArquivoNaoEncontrado {
            caminho: caminho.to_string(),
        });
    }

    let conteudo = fs::read_to_string(caminho)?; // Auto-converte io::Error
    let config: Config = toml::from_str(&conteudo)?; // Auto-converte toml::de::Error

    if config.host.is_none() {
        return Err(ConfigError::CampoAusente {
            campo: "host".to_string(),
        });
    }

    Ok(config)
}

Erros com Source Chain

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ServicoError {
    #[error("Falha ao processar pedido #{pedido_id}")]
    Processamento {
        pedido_id: u64,
        #[source]
        causa: Box<dyn std::error::Error + Send + Sync>,
    },

    #[error("Timeout ao consultar serviço externo")]
    Timeout {
        #[source]
        source: reqwest::Error,
    },
}

anyhow: Propagação Genérica para Aplicações

O anyhow fornece anyhow::Error — um tipo que pode conter qualquer erro, com suporte a contexto:

use anyhow::{Context, Result, bail, ensure, anyhow};
use std::fs;

// Result<T> é alias para Result<T, anyhow::Error>
fn ler_config(caminho: &str) -> Result<Config> {
    let conteudo = fs::read_to_string(caminho)
        .with_context(|| format!("Falha ao ler arquivo de configuração: {caminho}"))?;

    let config: Config = toml::from_str(&conteudo)
        .context("Falha ao fazer parse do TOML")?;

    // bail! retorna Err imediatamente
    if config.porta == 0 {
        bail!("Porta não pode ser zero");
    }

    // ensure! é como assert! mas retorna Err
    ensure!(config.porta > 1023, "Porta deve ser maior que 1023, recebido: {}", config.porta);

    Ok(config)
}

#[derive(serde::Deserialize)]
struct Config {
    host: String,
    porta: u16,
}

fn main() -> Result<()> {
    let config = ler_config("config.toml")?;
    println!("Servidor: {}:{}", config.host, config.porta);
    Ok(())
}

Contexto em Cadeias de Erros

use anyhow::{Context, Result};

async fn iniciar_servidor() -> Result<()> {
    let config = carregar_config()
        .context("Falha ao carregar configuração")?;

    let pool = conectar_banco(&config.database_url)
        .await
        .context("Falha ao conectar ao banco de dados")?;

    let listener = tokio::net::TcpListener::bind(&config.endereco)
        .await
        .with_context(|| format!("Falha ao vincular na porta {}", config.porta))?;

    println!("Servidor iniciado em {}", config.endereco);
    Ok(())
}

async fn carregar_config() -> Result<AppConfig> {
    // ...
    todo!()
}

async fn conectar_banco(url: &str) -> Result<()> {
    // ...
    todo!()
}

struct AppConfig {
    database_url: String,
    endereco: String,
    porta: u16,
}

Quando ocorre um erro, o anyhow mostra a cadeia completa de contexto:

Error: Falha ao carregar configuração

Caused by:
    0: Falha ao fazer parse do TOML
    1: expected value, found eof at line 3 column 1

Downcast para Tipos Específicos

use anyhow::Result;
use std::io;

fn processar() -> Result<()> {
    // ... pode gerar vários tipos de erro
    todo!()
}

fn main() {
    match processar() {
        Ok(_) => println!("Sucesso"),
        Err(e) => {
            // Tentar fazer downcast para tipo específico
            if let Some(io_err) = e.downcast_ref::<io::Error>() {
                eprintln!("Erro de I/O: {io_err}");
            } else {
                eprintln!("Erro: {e:#}"); // {:#} mostra a cadeia completa
            }
        }
    }
}

miette: Diagnósticos Ricos para CLIs

O miette é ideal quando você quer mostrar erros bonitos e informativos, como compiladores e ferramentas de desenvolvimento fazem:

use miette::{Diagnostic, SourceSpan, NamedSource, Result};
use thiserror::Error;

#[derive(Debug, Error, Diagnostic)]
#[error("Erro de sintaxe no arquivo de configuração")]
#[diagnostic(
    code(config::syntax_error),
    help("Verifique a sintaxe do arquivo. Consulte a documentação em https://exemplo.com/docs"),
    url("https://exemplo.com/erros/E001")
)]
struct ErroSintaxe {
    #[source_code]
    src: NamedSource<String>,

    #[label("erro encontrado aqui")]
    span: SourceSpan,

    #[label("esta seção pode estar relacionada")]
    contexto: Option<SourceSpan>,
}

fn validar_config(conteudo: &str, arquivo: &str) -> Result<()> {
    // Simular erro na posição 45, tamanho 3
    if conteudo.contains("xxx") {
        let pos = conteudo.find("xxx").unwrap();
        return Err(ErroSintaxe {
            src: NamedSource::new(arquivo, conteudo.to_string()),
            span: (pos, 3).into(),
            contexto: None,
        }.into());
    }
    Ok(())
}

fn main() -> Result<()> {
    miette::set_hook(Box::new(|_| {
        Box::new(
            miette::MietteHandlerOpts::new()
                .terminal_links(true)
                .unicode(true)
                .context_lines(2)
                .build(),
        )
    })).unwrap();

    let conteudo = r#"
[servidor]
host = "localhost"
porta = xxx
"#;

    validar_config(conteudo, "config.toml")?;
    Ok(())
}

Saída visual:

  × Erro de sintaxe no arquivo de configuração
   ╭─[config.toml:4:9]
 3 │ host = "localhost"
 4 │ porta = xxx
   ·         ─┬─
   ·          ╰── erro encontrado aqui
   ╰────
  help: Verifique a sintaxe do arquivo. Consulte a documentação.
  docs: https://exemplo.com/erros/E001

Múltiplos Erros

use miette::{Diagnostic, Report};
use thiserror::Error;

#[derive(Debug, Error, Diagnostic)]
#[error("Erros de validação encontrados")]
struct ErrosValidacao {
    #[related]
    erros: Vec<ErroValidacao>,
}

#[derive(Debug, Error, Diagnostic)]
#[error("{mensagem}")]
struct ErroValidacao {
    mensagem: String,

    #[help]
    dica: String,
}

fn validar_formulario(nome: &str, email: &str, idade: i32) -> Result<(), Report> {
    let mut erros = vec![];

    if nome.is_empty() {
        erros.push(ErroValidacao {
            mensagem: "Nome é obrigatório".to_string(),
            dica: "Forneça um nome com pelo menos 2 caracteres".to_string(),
        });
    }

    if !email.contains('@') {
        erros.push(ErroValidacao {
            mensagem: format!("Email inválido: '{email}'"),
            dica: "O email deve conter @ e um domínio válido".to_string(),
        });
    }

    if idade < 0 || idade > 150 {
        erros.push(ErroValidacao {
            mensagem: format!("Idade inválida: {idade}"),
            dica: "A idade deve estar entre 0 e 150".to_string(),
        });
    }

    if erros.is_empty() {
        Ok(())
    } else {
        Err(ErrosValidacao { erros }.into())
    }
}

Combinando as Três: Padrão Recomendado

O padrão mais comum em projetos Rust é:

  • thiserror para definir tipos de erro internos da biblioteca
  • anyhow na camada de aplicação para propagação fácil
  • miette quando a CLI precisa de output rico
// src/errors.rs — tipos de erro com thiserror
use thiserror::Error;

#[derive(Debug, Error)]
pub enum DomainError {
    #[error("Entidade não encontrada: {tipo} com id {id}")]
    NaoEncontrado { tipo: String, id: String },

    #[error("Operação não autorizada")]
    NaoAutorizado,

    #[error("Dados inválidos: {0}")]
    Validacao(String),
}

// src/service.rs — lógica de negócio retorna DomainError
pub fn buscar_usuario(id: &str) -> Result<String, DomainError> {
    if id == "0" {
        return Err(DomainError::NaoEncontrado {
            tipo: "usuario".to_string(),
            id: id.to_string(),
        });
    }
    Ok(format!("Usuario-{id}"))
}
// src/main.rs — aplicação usa anyhow para composição
use anyhow::{Context, Result};

mod errors;
mod service;

fn main() -> Result<()> {
    let usuario = service::buscar_usuario("42")
        .context("Falha ao buscar usuário para processar pedido")?;

    println!("Usuário encontrado: {usuario}");
    Ok(())
}

Integração com Frameworks Web

Com Axum

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use thiserror::Error;
use serde_json::json;

#[derive(Debug, Error)]
pub enum ApiError {
    #[error("Recurso não encontrado")]
    NaoEncontrado,

    #[error("Credenciais inválidas")]
    NaoAutorizado,

    #[error("Dados inválidos: {0}")]
    Validacao(String),

    #[error("Erro interno")]
    Interno(#[from] anyhow::Error),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, mensagem) = match &self {
            ApiError::NaoEncontrado => (StatusCode::NOT_FOUND, self.to_string()),
            ApiError::NaoAutorizado => (StatusCode::UNAUTHORIZED, self.to_string()),
            ApiError::Validacao(_) => (StatusCode::BAD_REQUEST, self.to_string()),
            ApiError::Interno(e) => {
                tracing::error!("Erro interno: {e:#}");
                (StatusCode::INTERNAL_SERVER_ERROR, "Erro interno do servidor".to_string())
            }
        };

        let body = Json(json!({
            "erro": mensagem,
            "status": status.as_u16(),
        }));

        (status, body).into_response()
    }
}

Quando Usar Cada Biblioteca

Use thiserror quando:

  • Está escrevendo uma library crate
  • Precisa de tipos de erro específicos e tipados
  • Quer que consumidores possam fazer match nos seus erros
  • Precisa implementar From para conversão automática com ?

Use anyhow quando:

  • Está escrevendo uma aplicação (não uma biblioteca)
  • Não precisa que o chamador faça match em tipos de erro específicos
  • Quer propagação fácil com .context()
  • Tem muitas fontes de erro diferentes e quer um tipo unificado

Use miette quando:

  • Está construindo uma CLI ou ferramenta de desenvolvimento
  • Quer output visual bonito para erros
  • Precisa apontar para localizações específicas no código/configuração do usuário
  • Quer fornecer dicas e links para documentação junto com erros

Diagrama de decisão:

Está escrevendo uma library?
  ├── Sim → use thiserror
  └── Não → é uma CLI com output rico?
      ├── Sim → use miette (+ thiserror para definições)
      └── Não → use anyhow (+ thiserror para tipos internos)

Conclusão

O ecossistema de error handling do Rust é maduro e ergonômico. A combinação de thiserror para definição de tipos e anyhow para propagação na aplicação cobre a maioria dos casos. Para CLIs e ferramentas, o miette adiciona diagnósticos visuais impressionantes. Dominar essas três bibliotecas é essencial para escrever Rust idiomático e manutenível.

Veja Também