Ir al contenido

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:

  • thiserror te ayuda a definir tipos de error. Sigues escribiendo el enum, pero un #[derive] escribe por ti Display, Error y From.
  • anyhow te 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”.

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.

Terminal window
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 80
la cantidad debe ser mayor que cero
InsufficientFunds { balance: 50, amount: 80 }
▶ Pruébalo tú mismo

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.

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 ámbito
use 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: 8080
error: falta `=` en la línea "port 8080"
error: el puerto no es un número válido
causado por: invalid digit found in string
▶ Pruébalo tú mismo

La ú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: 60
la línea 2 no contiene un número
causado por: invalid digit found in string
▶ Pruébalo tú mismo

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.5
error: invalid digit found in string
error: invalid float literal
▶ Pruébalo tú mismo

Sin 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 necesite format!.

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ón
no se pudo cargar la configuración: `99999` no es un puerto válido: number too large to fit in target type
0: no se pudo cargar la configuración
1: `99999` no es un puerto válido
2: number too large to fit in target type
▶ Pruébalo tú mismo

El ú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 type

Caused 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}"))?;

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 de return 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 cero
error: fondos insuficientes: el saldo es 100 e intentaste sacar 250
Ferris
error: no existe ningún usuario con id 7
▶ Pruébalo tú mismo

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(())
}
▶ Pruébalo tú mismo

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 string

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 completado
error: 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 menos
manzanas restantes: 3
▶ Pruébalo tú mismo

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 scope
help: 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.

  • thiserror sirve para definir errores: #[derive(Error)] más un mensaje #[error("...")] por variante sustituye a los impl Display e impl Error escritos 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 y anyhow! crea uno.
  • La combinación habitual: thiserror en el código de biblioteca y anyhow en main.

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