Manejo de errores con anyhow y thiserror en Rust
En las páginas de Result y de errores personalizados escribiste tus tipos de error a mano: un enum, un impl Display y, a veces, un impl From para que ? funcione. Es la forma correcta de aprender. Pero en un proyecto real acabas escribiendo el mismo código repetitivo una y otra vez.
Dos crates (paquetes de Rust) pequeños, del mismo autor, eliminan ese código repetitivo. Cada uno resuelve un problema distinto:
thiserrorte ayuda a definir tipos de error. Sigues escribiendo el enum, pero un#[derive]escribe por tiDisplay,ErroryFrom.anyhowte ayuda a pasar los errores hacia arriba. Te da un único tipo de error que puede contener cualquier error, y formas sencillas de añadir un mensaje como “al cargar el archivo de configuración”.
Bibliotecas frente a aplicaciones
Sección titulada «Bibliotecas frente a aplicaciones»Cuál elegir depende de quién va a manejar el error.
Una biblioteca es código al que llaman otras personas. Quien la llama puede querer reaccionar de forma distinta a cada fallo: reintentar si se agota el tiempo de espera, mostrar un aviso en el formulario si la entrada es incorrecta. Por eso una biblioteca debería devolver un enum preciso sobre el que se pueda usar match. Eso es thiserror.
Una aplicación (tu main.rs) suele ser la última parada. La mayoría de los errores acaban como un mensaje para una persona y, quizá, un código de salida. Casi nunca usas match sobre ellos: quieres poder pasarlos hacia arriba con ? y que se lean bien. Eso es anyhow.
Una buena regla general: thiserror para el código al que llaman otros, anyhow para la parte de arriba de tu programa. Muchos proyectos usan los dos, como verás al final de esta página.
Preparar el proyecto
Sección titulada «Preparar el proyecto»cargo add thiserror anyhow[dependencies]anyhow = "1"thiserror = "2"¿Es la primera vez que ves Cargo.toml? Lee Cargo y crates en Rust. Los dos crates están disponibles en el Playground de Rust, así que todos los botones para probar el código de esta página funcionan.
thiserror: derivar Error y escribir los mensajes
Sección titulada «thiserror: derivar Error y escribir los mensajes»Pon #[derive(Debug, Error)] encima de tu enum y dale a cada variante un mensaje con #[error("...")]. Ese mensaje se convierte en el texto de Display de la variante. Dentro de él, {0} es el primer campo de la tupla y {balance} es un campo con nombre:
use thiserror::Error;
#[derive(Debug, Error)]enum BankError { #[error("no se encontró la cuenta `{0}`")] NotFound(String), #[error("fondos insuficientes: el saldo es {balance} e intentaste sacar {amount}")] InsufficientFunds { balance: u32, amount: u32 }, #[error("la cantidad debe ser mayor que cero")] 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]);}Salida:
no se encontró la cuenta `alice`fondos insuficientes: el saldo es 50 e intentaste sacar 80la cantidad debe ser mayor que ceroInsufficientFunds { balance: 50, amount: 80 }Es exactamente el enum de la página de errores personalizados, pero todo el bloque impl Display ha desaparecido. Y no hay ninguna magia al ejecutar: thiserror solo escribe el código que tú habrías escrito a mano. Quien llama ve un enum normal.
thiserror: #[from] y #[source]
Sección titulada «thiserror: #[from] y #[source]»Los errores suelen tener una causa: “el puerto no es válido” porque “invalid digit found in string” (se encontró un dígito no válido en el texto). El trait estándar Error tiene un método .source() para eso, y thiserror lo rellena por ti.
#[from] hace dos cosas. Marca el campo como la causa (source) y escribe un impl From, así que ? convierte automáticamente el error interno en tu variante:
use std::error::Error; // trae el método .source() al ámbitouse std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]enum ConfigError { #[error("falta `=` en la línea {0:?}")] NoEquals(String), #[error("el puerto no es un 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 se convierte en ConfigError::BadPort Ok(port)}
fn main() { for line in ["port = 8080", "port 8080", "port = ochenta"] { match parse_port(line) { Ok(port) => println!("ok: {port}"), Err(e) => { println!("error: {e}"); if let Some(cause) = e.source() { println!(" causado por: {cause}"); } } } }}Salida:
ok: 8080error: falta `=` en la línea "port 8080"error: el puerto no es un número válido causado por: invalid digit found in stringLa última línea es el mensaje del propio ParseIntError de la biblioteca estándar, y Rust siempre lo escribe en inglés.
A veces quieres una causa pero no un From automático, porque necesitas añadir más datos, como el número de línea. En ese caso usa #[source] en el campo y construye el error tú mismo con .map_err(). (Un campo que se llame justo source se trata como la causa automáticamente.) Además, thiserror no solo funciona con enums, también con structs:
use std::error::Error;use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]#[error("la línea {line} no contiene un 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\nveinte\n30").unwrap_err(); println!("{err}"); println!("causado por: {}", err.source().unwrap());}Salida:
total: 60la línea 2 no contiene un númerocausado por: invalid digit found in stringanyhow: un solo tipo Result para todo
Sección titulada «anyhow: un solo tipo Result para todo»anyhow::Result<T> es una forma corta de escribir Result<T, anyhow::Error>. Un anyhow::Error puede contener cualquier tipo de error que implemente el trait estándar Error. Por eso ? funciona con todos ellos, incluso cuando una misma función se encuentra con varios tipos de error distintos:
use anyhow::Result;
// Dos tipos de error distintos: ParseIntError y ParseFloatError.// anyhow::Result acepta los dos a través de `?`.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"), ("tres", "2.50"), ("3", "barato")] { match total_price(quantity, price) { Ok(total) => println!("total: {total}"), Err(e) => println!("error: {e}"), } }}Salida:
total: 7.5error: invalid digit found in stringerror: invalid float literalSin anyhow necesitarías tu propio enum con dos variantes, o un Box<dyn Error>. anyhow es como un Box<dyn Error> más cómodo. Pero fíjate en el problema: “invalid digit found in string” y “invalid float literal” (número decimal no válido) son mensajes de Rust que no dicen qué valor estaba mal. Para eso sirve el contexto.
anyhow: añadir contexto e imprimir la cadena
Sección titulada «anyhow: añadir contexto e imprimir la cadena»Importa el trait Context y todos los Result ganan dos métodos nuevos:
.context("mensaje")envuelve el error con un mensaje fijo..with_context(|| format!(...))recibe una closure (función anónima). Solo construye el mensaje si de verdad hay un error, así que úsalo cuando el mensaje necesiteformat!.
Cada capa de contexto añade un eslabón a una cadena de errores. La forma de imprimir el error decide cuánta parte de la cadena ves:
use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> { let port: u16 = text .parse() .with_context(|| format!("`{text}` no es un puerto válido"))?; Ok(port)}
fn load_settings(port_text: &str) -> Result<u16> { let port = parse_port(port_text).context("no se pudo cargar la configuración")?; Ok(port)}
fn main() { let err = load_settings("99999").unwrap_err();
println!("{err}"); // solo el mensaje más externo println!("{err:#}"); // toda la cadena en una línea
for (i, cause) in err.chain().enumerate() { println!("{i}: {cause}"); }}Salida:
no se pudo cargar la configuraciónno se pudo cargar la configuración: `99999` no es un puerto válido: number too large to fit in target type0: no se pudo cargar la configuración1: `99999` no es un puerto válido2: number too large to fit in target typeEl último eslabón es el mensaje original de Rust (“el número es demasiado grande para el tipo de destino”): 99999 no cabe en un u16.
El tercer formato es {:?}. Imprime un informe de varias líneas, que es lo que ves cuando main devuelve un error:
println!("{err:?}");no se pudo cargar la configuración
Caused by: 0: `99999` no es un puerto válido 1: number too large to fit in target typeCaused by: (“causado por”) lo escribe anyhow, siempre en inglés. Si la variable de entorno RUST_BACKTRACE=1 está definida, {:?} añade también un backtrace (la pila de llamadas) que muestra dónde se creó el error. Consulta Variables de entorno.
Un uso real típico es leer archivos, donde el mensaje del sistema operativo (“No such file or directory”) nunca dice qué archivo era:
let text = std::fs::read_to_string(path) .with_context(|| format!("no se pudo leer el archivo de configuración {path}"))?;anyhow: bail!, ensure! y anyhow!
Sección titulada «anyhow: bail!, ensure! y anyhow!»A veces no hay ningún error de fondo. Simplemente decides que algo está mal. anyhow tiene tres macros para eso:
bail!("...")devuelve un error en ese mismo momento. Es una forma corta dereturn Err(anyhow!("...")).ensure!(condición, "...")devuelve un error si la condición es falsa.anyhow!("...")crea un valor de error sin devolverlo.
Las tres aceptan mensajes con el estilo de format!.
use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> { ensure!(amount > 0, "la cantidad debe ser mayor que cero"); if amount > balance { bail!("fondos insuficientes: el saldo es {balance} e intentaste sacar {amount}"); } Ok(balance - amount)}
fn find_user(id: u32) -> Result<&'static str> { match id { 1 => Ok("Ferris"), _ => Err(anyhow!("no existe ningún usuario con 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!("error: {e}"); } } println!("{}", find_user(1).unwrap()); println!("error: {}", find_user(7).unwrap_err());}Salida:
Some(70)error: la cantidad debe ser mayor que ceroerror: fondos insuficientes: el saldo es 100 e intentaste sacar 250Ferriserror: no existe ningún usuario con id 7Devolver anyhow::Result desde main
Sección titulada «Devolver anyhow::Result desde main»Aquí es donde más vas a ver anyhow: main devuelve anyhow::Result<()> y usas ? en todas partes. Si un error llega hasta main, Rust lo imprime con {:?} en stderr (la salida de errores) y termina con el código de salida 1:
use anyhow::{Context, Result};
fn main() -> Result<()> { let text = "cuarenta y dos"; let n: i32 = text .parse() .with_context(|| format!("no se pudo leer {text:?} como número"))?; println!("{n}"); Ok(())}No se imprime nada en stdout. En stderr ves:
Error: no se pudo leer "cuarenta y dos" como número
Caused by: invalid digit found in stringUsar los dos juntos
Sección titulada «Usar los dos juntos»Esta es la forma habitual de un proyecto real. El módulo inventory hace de biblioteca: devuelve un InventoryError preciso, hecho con thiserror. El código de la aplicación usa anyhow, añade contexto y solo mira dentro del error donde de verdad le importa. downcast_ref le pregunta a un anyhow::Error “¿en realidad eres de este tipo?”, y funciona aunque se le haya añadido contexto:
// ----- parte de "biblioteca": errores precisos con thiserror -----mod inventory { use thiserror::Error;
#[derive(Debug, Error)] pub enum InventoryError { #[error("artículo desconocido `{0}`")] UnknownItem(String), #[error("solo quedan {available}, no se pueden sacar {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 != "manzana" { return Err(InventoryError::UnknownItem(item.to_string())); } if wanted > self.apples { return Err(InventoryError::NotEnough { available: self.apples, wanted }); } self.apples -= wanted; Ok(()) } }}
// ----- parte de "aplicación": 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!("el pedido #{id} falló"))?; println!("pedido #{id} completado"); Ok(())}
fn main() { let mut store = Store { apples: 5 }; let orders = [(1, "manzana", 2), (2, "ciruela", 1), (3, "manzana", 9)];
for (id, item, amount) in orders { if let Err(e) = process_order(&mut store, id, item, amount) { println!("error: {e:#}"); // Aún podemos mirar dentro y buscar el tipo original de thiserror: if let Some(InventoryError::NotEnough { available, .. }) = e.downcast_ref::<InventoryError>() { println!(" pista: prueba a pedir {available} o menos"); } } } println!("manzanas restantes: {}", store.apples);}Salida:
pedido #1 completadoerror: el pedido #2 falló: artículo desconocido `ciruela`error: el pedido #3 falló: solo quedan 3, no se pueden sacar 9 pista: prueba a pedir 3 o menosmanzanas restantes: 3Errores comunes
Sección titulada «Errores comunes»Olvidar importar Context. .context() es un método del trait anyhow::Context, y los métodos de un trait solo funcionan cuando el trait está en el ámbito:
use anyhow::Result;
fn parse(text: &str) -> Result<i32> { let n: i32 = text.parse().context("not a number")?; 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("not a number")?; | ^^^^^^^ | = 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; |El compilador dice que no encuentra ningún método llamado context, porque el trait que lo ofrece no está en el ámbito, y hasta te sugiere la línea que falta. Solución: use anyhow::{Context, Result};.
Repetir el mensaje de la causa en tu propio mensaje. Con #[from], el error interno ya es la causa. Si además pones {0} en el mensaje, un informe completo lo imprime dos veces:
#[derive(Debug, thiserror::Error)]enum AppError { #[error("puerto no válido: {0}")] // incluye el texto de la causa... BadPort(#[from] ParseIntError), // ...y este campo también es la causa}Si lo imprimes con {:#} a través de anyhow, obtienes puerto no válido: invalid digit found in string: invalid digit found in string. Solución: o escribes #[error("puerto no válido")] y dejas que la cadena muestre la causa, o mantienes {0} y no marcas el campo como causa. Elige una de las dos.
Imprimir con {} y perder la causa. println!("{e}") sobre un anyhow::Error muestra solo el mensaje más externo, como “no se pudo cargar la configuración”. Cuando imprimas un error para una persona, usa {:#} (una línea) o {:?} (informe completo).
Usar anyhow en la API pública de una biblioteca. Si tu biblioteca devuelve anyhow::Result, quien la llama no puede usar match sobre tus errores. Tendría que adivinar el tipo con downcast_ref. Dale a tus funciones públicas un enum hecho con thiserror y deja anyhow para aplicaciones, tests y ejemplos.
Resumen
Sección titulada «Resumen»- thiserror sirve para definir errores:
#[derive(Error)]más un mensaje#[error("...")]por variante sustituye a losimpl Displayeimpl Errorescritos a mano. #[from]hace que?convierta un error interno en tu variante y lo guarda como causa.#[source]guarda la causa sin la conversión.- anyhow sirve para pasar los errores hacia arriba:
anyhow::Result<T>acepta cualquier error a través de?. .context()y.with_context()añaden a la cadena de errores una capa que una persona puede entender. Imprime la cadena con{:#}(una línea) o{:?}(informe).bail!devuelve un error en el acto,ensure!devuelve uno si una condición es falsa yanyhow!crea uno.- La combinación habitual: thiserror en el código de biblioteca y anyhow en
main.
Comprueba lo que aprendiste
Sección titulada «Comprueba lo que aprendiste»Comprueba lo aprendido
1. Estás escribiendo una biblioteca y quien la llama necesita reaccionar de forma distinta a un tiempo de espera agotado y a una entrada incorrecta. ¿Qué crate encaja mejor?
2. ¿Qué hace #[from] en el campo de una variante?
3. ¿Qué formato imprime un anyhow::Error con toda su cadena en una sola línea?
4. ¿Cuándo deberías usar .with_context(|| ...) en lugar de .context(...)?
5. ¿Qué hace ensure!(amount > 0, "la cantidad debe ser positiva") cuando amount vale 0?
Puntuación: 0 / 5
Para leer más
Sección titulada «Para leer más»- Documentación de anyhow — docs.rs (en inglés): toda la API, incluidos
Context,chain()y el downcasting. - Documentación de thiserror — docs.rs (en inglés): todos los atributos, como
#[error(transparent)]y los campos de backtrace. - El trait Context — anyhow (en inglés): cómo funcionan
contextywith_context, con ejemplos. - Error Handling — El libro de Rust (en inglés): los fundamentos sobre los que se apoyan los dos crates.
- std::error::Error (en inglés): el trait estándar que hay detrás de
.source()y de las cadenas de errores.