Arquitetura de tratamento de erros
Esta aula explora a arquitetura de tratamento de erros em Rust, diferenciando erros de biblioteca e de aplicação, apresentando as crates thiserror e anyhow para estruturar erros de forma ergonômica, e discutindo boas práticas para criar sistemas de erro robustos e manuteníveis.
Nesta aula, vamos mergulhar na arquitetura de tratamento de erros em Rust, um dos aspectos mais importantes para construir software robusto e confiável. Você aprenderá a distinguir entre erros que fazem parte da API de uma biblioteca e aqueles que pertencem à lógica de uma aplicação, e como usar as crates thiserror e anyhow para criar hierarquias de erro claras e contextos ricos.
Dominar o tratamento de erros é essencial para escrever código Rust idiomático. Vamos explorar padrões de design, práticas recomendadas e exemplos práticos que você pode aplicar imediatamente em seus projetos.
Erros de biblioteca vs aplicação
Em Rust, a distinção entre erros de biblioteca e de aplicação é fundamental para projetar APIs limpas e sistemas de tratamento de erros eficazes. Erros de biblioteca são aqueles expostos por uma crate para que os consumidores possam reagir programaticamente a eles. Eles devem ser tipados, ou seja, representados por um tipo de erro específico que implementa std::error::Error, permitindo que quem usa a biblioteca faça match sobre as variantes e tome decisões precisas. Por outro lado, erros de aplicação são geralmente mais abrangentes e focados em fornecer contexto para o usuário final, como mensagens de log ou respostas HTTP. Eles podem ser menos estruturados e mais voltados para a apresentação.
Um erro de biblioteca deve ser minimalista e autoexplicativo. Por exemplo, uma biblioteca de acesso a banco de dados pode ter um erro ConnectionError com variantes como Timeout e AuthenticationFailed. Já em uma aplicação web, você pode querer envolver esses erros com informações adicionais, como o endpoint que falhou e o ID do usuário, para facilitar o diagnóstico. Essa separação permite que a biblioteca seja reutilizável e o tratamento de erros na aplicação seja mais expressivo.
Aqui está um exemplo de como definir um erro de biblioteca:
use std::fmt;
#[derive(Debug)]
pub enum DatabaseError {
ConnectionTimeout,
AuthenticationFailed { username: String },
QueryError(String),
}
impl fmt::Display for DatabaseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DatabaseError::ConnectionTimeout => write!(f, "Connection timed out"),
DatabaseError::AuthenticationFailed { username } => write!(f, "Authentication failed for {}", username),
DatabaseError::QueryError(msg) => write!(f, "Query error: {}", msg),
}
}
}
impl std::error::Error for DatabaseError {}
Já na aplicação, você pode ter um tipo de erro mais amplo que engloba erros de várias bibliotecas e adiciona contexto:
use std::fmt;
#[derive(Debug)]
pub enum AppError {
Database(DatabaseError),
NotFound(String),
PermissionDenied,
Internal(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Database(e) => write!(f, "Database error: {}", e),
AppError::NotFound(resource) => write!(f, "Resource '{}' not found", resource),
AppError::PermissionDenied => write!(f, "Permission denied"),
AppError::Internal(msg) => write!(f, "Internal error: {}", msg),
}
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AppError::Database(e) => Some(e),
_ => None,
}
}
}
Observe que o AppError pode conter um DatabaseError como fonte, permitindo rastrear a causa raiz. Essa é uma boa prática para manter a cadeia de erros.
thiserror + anyhow
Escrever implementações manuais de Display e Error para cada tipo de erro é repetitivo e propenso a erros. A crate thiserror automatiza esse processo para bibliotecas, enquanto anyhow é voltado para aplicações, facilitando o manuseio de erros com contexto e conversões automáticas. Essas duas crates são complementares e amplamente adotadas no ecossistema Rust.
thiserror permite que você defina um enum de erros com anotações simples, e ele gera automaticamente as implementações de Display e Error. Isso reduz drasticamente o boilerplate e mantém o código legível. Por exemplo:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("connection timed out")]
ConnectionTimeout,
#[error("authentication failed for user '{username}'")]
AuthenticationFailed { username: String },
#[error("query error: {0}")]
QueryError(String),
}
Nesse exemplo, o macro #[error("...")] define a mensagem de exibição. Para variantes que possuem campos, você pode usar placeholders como {username} ou {0} para referenciar o primeiro campo.
Já anyhow fornece o tipo anyhow::Error, que pode representar qualquer erro que implemente std::error::Error. Ele permite usar o operador ? em funções que retornam anyhow::Result<T> sem se preocupar com os tipos de erro específicos, pois eles são convertidos automaticamente. Isso é ideal para aplicações e binários, onde você precisa de simplicidade e flexibilidade. Veja um exemplo:
use anyhow::{Context, Result};
fn read_config() -> Result<String> {
let content = std::fs::read_to_string("config.toml")
.context("failed to read config file")?;
Ok(content)
}
Aqui, Context adiciona uma mensagem descritiva ao erro original, e o ? propaga o erro com esse contexto. Essa combinação torna o código de aplicação muito mais limpo.
Uma prática comum é usar thiserror em bibliotecas para definir erros tipados e anyhow nos binários e aplicações para lidar com esses erros de forma ergonômica, adicionando contexto conforme necessário.
Contexto
Adicionar contexto aos erros é crucial para o diagnóstico eficaz. O contexto fornece informações sobre o que estava sendo feito quando o erro ocorreu, além da causa raiz. Em Rust, o padrão é usar o método context da crate anyhow (ou with_context para contexto lazy). Isso transforma um erro simples em uma mensagem rica que ajuda a depurar problemas.
Por exemplo, se você está lendo um arquivo de configuração e ocorre um erro de I/O, o contexto pode indicar qual arquivo estava sendo lido e em qual operação. Sem contexto, você veria apenas o erro de I/O bruto, que pode ser difícil de relacionar ao problema real.
use anyhow::{Context, Result};
use std::fs;
fn load_user_preferences(user_id: u32) -> Result<String> {
let path = format!("prefs/{}.json", user_id);
let data = fs::read_to_string(&path)
.with_context(|| format!("failed to load preferences for user {}", user_id))?;
Ok(data)
}
Nesse exemplo, se a leitura falhar, o erro resultante conterá a mensagem original mais o contexto fornecido, permitindo que você saiba imediatamente qual usuário estava envolvido.
O contexto também pode ser usado em cadeias de operações, onde cada etapa adiciona informações. Isso cria uma trilha de erro clara, que é inestimável ao depurar sistemas complexos.
Boas práticas
Ao projetar arquiteturas de tratamento de erros, algumas boas práticas se destacam. Primeiro, defina erros tipados em bibliotecas para que consumidores possam reagir programaticamente. Use thiserror para reduzir boilerplate. Em aplicações, use anyhow para simplificar a propagação e adicionar contexto. Evite usar unwrap() ou expect() em código de produção, a menos que você tenha certeza absoluta de que a operação não pode falhar. Prefira usar ? e retornar erros.
Outra prática importante é implementar a trait Error corretamente, incluindo o método source() quando houver uma causa subjacente. Isso permite que ferramentas como anyhow mostrem toda a cadeia de erros. Além disso, mantenha seus tipos de erro no mesmo nível de abstração da operação: não coloque detalhes de baixo nível em erros de alto nível, a menos que seja necessário.
Considere também a consistência: defina uma estratégia única para toda a aplicação. Por exemplo, ter um tipo AppError central que engloba todos os erros de negócios, e usar anyhow::Error para erros internos não críticos. Documente os erros com doc comments para que outros desenvolvedores saibam o que cada variante significa e quando ela ocorre.
Por fim, teste o tratamento de erros. Escreva testes que verifiquem se os erros são retornados quando esperado e se as mensagens de contexto estão corretas. Isso garante que sua arquitetura de erros funcione como projetado.
Referências
- Rust Book - Error Handling
- Rust Standard Library - Error trait
- thiserror crate documentation
- anyhow crate documentation
- Rust by Example - Error handling
- BurntSushi's Rust Error Handling Guide
- Luca Palmieri - Error Handling in Rust
Exercícios
Crie um tipo de erro de biblioteca chamado
ParseErrorusandothiserrorpara representar erros de parsing de um formato simples. Inclua variantes para falha de sintaxe e campo inválido.✓ Resposta:use thiserror::Error; #[derive(Error, Debug)] pub enum ParseError { #[error("syntax error at line {line}")] SyntaxError { line: usize }, #[error("invalid field '{field}'")] InvalidField { field: String }, }Escreva uma função que tente ler um arquivo e adicione contexto usando
anyhow. A função deve retornaranyhow::Result<String>e incluir o caminho do arquivo no contexto.✓ Resposta:use anyhow::{Context, Result}; fn read_file(path: &str) -> Result<String> { std::fs::read_to_string(path) .with_context(|| format!("failed to read file '{}'", path)) }Dado um enum de erro de biblioteca, implemente manualmente a trait
ErroreDisplaypara uma variante que contenha uma causa subjacente. Use o métodosource().✓ Resposta:use std::fmt; use std::error::Error as StdError; #[derive(Debug)] pub enum MyError { Wrapped(io::Error), } impl fmt::Display for MyError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { MyError::Wrapped(e) => write!(f, "wrapped error: {}", e), } } } impl StdError for MyError { fn source(&self) -> Option<&(dyn StdError + 'static)> { match self { MyError::Wrapped(e) => Some(e), } } }Explique a diferença entre
unwrap()e?e quando é apropriado usar cada um. Dê um exemplo de código que use?para propagar um erro.✓ Resposta:unwrap()extrai o valor de umResult<T, E>e causa pânico se forErr. Deve ser usado apenas quando você tem certeza absoluta de que a operação não falhará (por exemplo, em testes ou invariantes). O operador?propaga o erro para a função chamadora, retornandoErrprematuramente, permitindo que o chamador decida como lidar. Exemplo:fn get_user_name(id: u32) -> Result<String, io::Error> { let content = std::fs::read_to_string("users.txt")?; // ... processa e retorna o nome Ok(content) }Projete uma arquitetura de erros para uma aplicação de linha de comando que envolva um banco de dados e um serviço HTTP. Use
thiserrorpara os erros da biblioteca de banco de dados eanyhowna aplicação, adicionando contexto. Escreva um exemplo de função que retorneanyhow::Resulte que utilize?para propagar erros da biblioteca.✓ Resposta:use anyhow::{Context, Result}; use thiserror::Error; #[derive(Error, Debug)] pub enum DbError { #[error("connection failed")] ConnectionFailed, #[error("query failed: {0}")] QueryFailed(String), } fn execute_query(db: &str) -> Result<()> { // Simula uma operação que pode falhar let result = Err(DbError::ConnectionFailed) .context("failed to connect to database")?; Ok(result) } fn main() -> Result<()> { execute_query("prod.db")?; Ok(()) }
Lembre-se de que o tratamento de erros é uma parte fundamental da engenharia de software. Uma boa arquitetura de erros não só melhora a robustez do seu código, mas também a experiência de desenvolvimento e a capacidade de manutenção. Pratique esses padrões em seus projetos e você verá a diferença.