Zum Inhalt springen

Fehlerbehandlung in Rust mit anyhow und thiserror

Auf den Seiten Result und Eigene Fehlertypen hast du Fehlertypen von Hand geschrieben: ein Enum, ein impl Display und vielleicht noch ein impl From, damit ? funktioniert. So lernt man es richtig. In einem echten Projekt schreibst du aber immer wieder denselben Boilerplate-Code.

Zwei kleine Crates vom selben Autor nehmen dir diesen Boilerplate-Code ab. Sie lösen zwei verschiedene Probleme:

  • thiserror hilft dir, Fehlertypen zu definieren. Das Enum schreibst du weiterhin selbst, aber ein #[derive] erzeugt Display, Error und From für dich.
  • anyhow hilft dir, Fehler weiterzureichen. Es gibt dir einen einzigen Fehlertyp, der jeden Fehler aufnehmen kann, und einfache Wege, eine Meldung wie „beim Laden der Konfigurationsdatei“ hinzuzufügen.

Welches Crate du nimmst, hängt davon ab, wer den Fehler behandelt.

Eine Bibliothek ist Code, den andere aufrufen. Ihre Aufrufer wollen vielleicht auf jeden Fehlschlag anders reagieren: bei einem Timeout einen neuen Versuch starten, bei falscher Eingabe einen Hinweis im Formular zeigen. Eine Bibliothek sollte deshalb ein genaues Enum zurückgeben, auf das Aufrufer mit match reagieren können. Dafür gibt es thiserror.

Eine Anwendung (deine main.rs) ist meistens die letzte Station. Die meisten Fehler landen am Ende als Meldung für einen Menschen, vielleicht noch mit einem Exit-Code. Mit match reagierst du selten darauf. Du willst sie einfach mit ? nach oben reichen und gut lesen können. Dafür gibt es anyhow.

Eine gute Faustregel: thiserror für Code, den andere aufrufen, anyhow für die oberste Ebene deines Programms. Viele Projekte benutzen beide, wie du am Ende dieser Seite sehen wirst.

Terminal window
cargo add thiserror anyhow
[dependencies]
anyhow = "1"
thiserror = "2"

Neu bei Cargo.toml? Sieh dir Cargo und Crates an. Beide Crates gibt es im Rust Playground, also funktioniert jeder „Try it Yourself“-Button auf dieser Seite.

Schreib #[derive(Debug, Error)] über dein Enum und gib jeder Variante eine Meldung mit #[error("...")]. Diese Meldung wird zum Display-Text der Variante. Darin steht {0} für das erste Tupel-Feld und {balance} für ein benanntes Feld:

use thiserror::Error;
#[derive(Debug, Error)]
enum BankError {
#[error("Konto `{0}` nicht gefunden")]
NotFound(String),
#[error("nicht genug Guthaben: Kontostand ist {balance}, abheben wolltest du {amount}")]
InsufficientFunds { balance: u32, amount: u32 },
#[error("der Betrag muss größer als null sein")]
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]);
}

Ausgabe:

Konto `alice` nicht gefunden
nicht genug Guthaben: Kontostand ist 50, abheben wolltest du 80
der Betrag muss größer als null sein
InsufficientFunds { balance: 50, amount: 80 }
▶ Selbst ausprobieren

Das ist genau das Enum von der Seite Eigene Fehlertypen, aber der ganze impl Display-Block ist verschwunden. Zur Laufzeit passiert dabei nichts Verstecktes: thiserror schreibt nur den Code, den du sonst von Hand geschrieben hättest. Aufrufer sehen ein ganz normales Enum.

Fehler haben oft eine Ursache: „Der Port ist ungültig“, weil „im String steht eine ungültige Ziffer“. Der Standard-Trait Error hat dafür die Methode .source(). thiserror füllt sie für dich aus.

#[from] macht zwei Dinge. Es markiert das Feld als Ursache (Source), und es schreibt ein impl From. Dadurch wandelt ? den inneren Fehler automatisch in deine Variante um:

use std::error::Error; // holt die Methode .source() in den Scope
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
enum ConfigError {
#[error("fehlendes `=` in Zeile {0:?}")]
NoEquals(String),
#[error("der Port ist keine gültige Zahl")]
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>()?; // aus ParseIntError wird ConfigError::BadPort
Ok(port)
}
fn main() {
for line in ["port = 8080", "port 8080", "port = achtzig"] {
match parse_port(line) {
Ok(port) => println!("ok: {port}"),
Err(e) => {
println!("Fehler: {e}");
if let Some(cause) = e.source() {
println!(" Ursache: {cause}");
}
}
}
}
}

Ausgabe:

ok: 8080
Fehler: fehlendes `=` in Zeile "port 8080"
Fehler: der Port ist keine gültige Zahl
Ursache: invalid digit found in string
▶ Selbst ausprobieren

Die letzte Zeile, „invalid digit found in string“ („ungültige Ziffer im String gefunden“), stammt aus der Standardbibliothek und ist deshalb auf Englisch.

Manchmal willst du eine Ursache, aber kein automatisches From, weil du zusätzliche Daten brauchst, zum Beispiel eine Zeilennummer. Dann schreibst du stattdessen #[source] an das Feld und baust den Fehler selbst mit .map_err(). (Ein Feld, das einfach source heißt, gilt automatisch als Ursache.) thiserror funktioniert übrigens auch mit Structs, nicht nur mit Enums:

use std::error::Error;
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
#[error("Zeile {line} enthält keine Zahl")]
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!("Summe: {}", sum_lines("10\n20\n30").unwrap());
let err = sum_lines("10\nzwanzig\n30").unwrap_err();
println!("{err}");
println!("Ursache: {}", err.source().unwrap());
}

Ausgabe:

Summe: 60
Zeile 2 enthält keine Zahl
Ursache: invalid digit found in string
▶ Selbst ausprobieren

anyhow::Result<T> ist die Kurzform für Result<T, anyhow::Error>. Ein anyhow::Error kann jeden Fehlertyp aufnehmen, der den Standard-Trait Error implementiert. Deshalb funktioniert ? mit allen davon, auch wenn eine Funktion auf mehrere verschiedene Fehlertypen trifft:

use anyhow::Result;
// Zwei verschiedene Fehlertypen: ParseIntError und ParseFloatError.
// anyhow::Result nimmt beide über `?` an.
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"), ("drei", "2.50"), ("3", "billig")] {
match total_price(quantity, price) {
Ok(total) => println!("Summe: {total}"),
Err(e) => println!("Fehler: {e}"),
}
}
}

Ausgabe:

Summe: 7.5
Fehler: invalid digit found in string
Fehler: invalid float literal
▶ Selbst ausprobieren

Ohne anyhow bräuchtest du ein eigenes Enum mit zwei Varianten oder eine Box<dyn Error>. anyhow ist so etwas wie eine angenehmere Box<dyn Error>. Aber sieh dir das Problem an: „invalid digit found in string“ (ungültige Ziffer) und „invalid float literal“ (ungültige Kommazahl) sagen nicht, welcher Wert falsch war. Genau dafür gibt es Kontext.

anyhow: Kontext hinzufügen und die Kette ausgeben

Abschnitt betitelt „anyhow: Kontext hinzufügen und die Kette ausgeben“

Importiere den Trait Context, und jedes Result bekommt zwei neue Methoden:

  • .context("Meldung") packt den Fehler mit einer festen Meldung ein.
  • .with_context(|| format!(...)) nimmt ein Closure. Es baut die Meldung nur, wenn wirklich ein Fehler aufgetreten ist. Nimm es also, wenn die Meldung format! braucht.

Jede Schicht Kontext fügt einer Fehlerkette (Error Chain) ein Glied hinzu. Wie du den Fehler ausgibst, entscheidet, wie viel von der Kette du siehst:

use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> {
let port: u16 = text
.parse()
.with_context(|| format!("`{text}` ist kein gültiger Port"))?;
Ok(port)
}
fn load_settings(port_text: &str) -> Result<u16> {
let port = parse_port(port_text).context("Einstellungen konnten nicht geladen werden")?;
Ok(port)
}
fn main() {
let err = load_settings("99999").unwrap_err();
println!("{err}"); // nur die äußerste Meldung
println!("{err:#}"); // die ganze Kette in einer Zeile
for (i, cause) in err.chain().enumerate() {
println!("{i}: {cause}");
}
}

Ausgabe:

Einstellungen konnten nicht geladen werden
Einstellungen konnten nicht geladen werden: `99999` ist kein gültiger Port: number too large to fit in target type
0: Einstellungen konnten nicht geladen werden
1: `99999` ist kein gültiger Port
2: number too large to fit in target type
▶ Selbst ausprobieren

„number too large to fit in target type“ ist wieder eine Meldung der Standardbibliothek: Die Zahl ist zu groß für den Zieltyp (ein u16 reicht nur bis 65535).

Das dritte Format ist {:?}. Es gibt einen mehrzeiligen Bericht aus. Genau den siehst du auch, wenn main einen Fehler zurückgibt:

println!("{err:?}");
Einstellungen konnten nicht geladen werden
Caused by:
0: `99999` ist kein gültiger Port
1: number too large to fit in target type

Die Überschrift „Caused by:“ („verursacht durch“) schreibt anyhow selbst, sie bleibt immer auf Englisch.

Ist die Umgebungsvariable RUST_BACKTRACE=1 gesetzt, hängt {:?} außerdem einen Backtrace an, der zeigt, wo der Fehler entstanden ist. Mehr dazu unter Umgebung und Prozesse.

Ein typischer Einsatz in der Praxis ist das Lesen von Dateien. Die Meldung des Betriebssystems („No such file or directory“, also „Datei oder Verzeichnis nicht gefunden“) sagt nämlich nie, um welche Datei es geht:

let text = std::fs::read_to_string(path)
.with_context(|| format!("Konfigurationsdatei {path} konnte nicht gelesen werden"))?;

Manchmal gibt es gar keinen zugrunde liegenden Fehler. Du entscheidest einfach selbst, dass etwas nicht stimmt. Dafür hat anyhow drei Makros:

  • bail!("...") gibt sofort einen Fehler zurück. Es ist die Kurzform für return Err(anyhow!("...")).
  • ensure!(condition, "...") gibt einen Fehler zurück, wenn die Bedingung falsch ist.
  • anyhow!("...") erzeugt einen Fehlerwert, ohne ihn zurückzugeben.

Alle drei nehmen Meldungen im Stil von format! an.

use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> {
ensure!(amount > 0, "der Betrag muss größer als null sein");
if amount > balance {
bail!("nicht genug Guthaben: Kontostand ist {balance}, abheben wolltest du {amount}");
}
Ok(balance - amount)
}
fn find_user(id: u32) -> Result<&'static str> {
match id {
1 => Ok("Ferris"),
_ => Err(anyhow!("kein Benutzer mit der 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!("Fehler: {e}");
}
}
println!("{}", find_user(1).unwrap());
println!("Fehler: {}", find_user(7).unwrap_err());
}

Ausgabe:

Some(70)
Fehler: der Betrag muss größer als null sein
Fehler: nicht genug Guthaben: Kontostand ist 100, abheben wolltest du 250
Ferris
Fehler: kein Benutzer mit der ID 7
▶ Selbst ausprobieren

Am häufigsten begegnet dir anyhow genau hier: main gibt anyhow::Result<()> zurück, und du benutzt überall ?. Kommt ein Fehler bis in main, gibt Rust ihn mit {:?} auf stderr aus und beendet das Programm mit dem Exit-Code 1:

use anyhow::{Context, Result};
fn main() -> Result<()> {
let text = "zweiundvierzig";
let n: i32 = text
.parse()
.with_context(|| format!("{text:?} konnte nicht als Zahl gelesen werden"))?;
println!("{n}");
Ok(())
}
▶ Selbst ausprobieren

Auf stdout wird nichts ausgegeben. Auf stderr siehst du:

Error: "zweiundvierzig" konnte nicht als Zahl gelesen werden
Caused by:
invalid digit found in string

Das Error: am Anfang fügt Rust selbst hinzu, wenn main einen Fehler zurückgibt. Deshalb steht es auf Englisch da.

So sieht ein echtes Projekt meistens aus. Das Modul inventory spielt die Rolle einer Bibliothek: Es gibt einen genauen InventoryError zurück, der mit thiserror gebaut ist. Der Anwendungscode benutzt anyhow, fügt Kontext hinzu und schaut nur dort in den Fehler hinein, wo es ihn wirklich interessiert. downcast_ref fragt einen anyhow::Error: „Bist du in Wirklichkeit dieser Typ?“ Das funktioniert sogar durch hinzugefügten Kontext hindurch:

// ----- Bibliotheksteil: genaue Fehler mit thiserror -----
mod inventory {
use thiserror::Error;
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("unbekannter Artikel `{0}`")]
UnknownItem(String),
#[error("nur noch {available} übrig, {wanted} sind nicht möglich")]
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 != "Apfel" {
return Err(InventoryError::UnknownItem(item.to_string()));
}
if wanted > self.apples {
return Err(InventoryError::NotEnough { available: self.apples, wanted });
}
self.apples -= wanted;
Ok(())
}
}
}
// ----- Anwendungsteil: anyhow + Kontext -----
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!("Bestellung #{id} fehlgeschlagen"))?;
println!("Bestellung #{id} erledigt");
Ok(())
}
fn main() {
let mut store = Store { apples: 5 };
let orders = [(1, "Apfel", 2), (2, "Pflaume", 1), (3, "Apfel", 9)];
for (id, item, amount) in orders {
if let Err(e) = process_order(&mut store, id, item, amount) {
println!("Fehler: {e:#}");
// Wir können trotzdem nach dem ursprünglichen thiserror-Typ schauen:
if let Some(InventoryError::NotEnough { available, .. }) =
e.downcast_ref::<InventoryError>()
{
println!(" Tipp: Bestell höchstens {available}");
}
}
}
println!("Übrige Äpfel: {}", store.apples);
}

Ausgabe:

Bestellung #1 erledigt
Fehler: Bestellung #2 fehlgeschlagen: unbekannter Artikel `Pflaume`
Fehler: Bestellung #3 fehlgeschlagen: nur noch 3 übrig, 9 sind nicht möglich
Tipp: Bestell höchstens 3
Übrige Äpfel: 3
▶ Selbst ausprobieren

Context nicht importieren. .context() ist eine Methode aus dem Trait anyhow::Context, und Trait-Methoden funktionieren nur, wenn der Trait im Scope ist:

use anyhow::Result;
fn parse(text: &str) -> Result<i32> {
let n: i32 = text.parse().context("keine Zahl")?;
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("keine Zahl")?;
| ^^^^^^^
|
= 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;
|

Der Compiler sagt: Für Result gibt es keine Methode context, weil der Trait, der sie bereitstellt, nicht im Scope ist. Er schlägt dir sogar gleich den passenden Import vor.

Die Lösung: use anyhow::{Context, Result};.

Die Meldung der Ursache in der eigenen Meldung wiederholen. Mit #[from] ist der innere Fehler bereits die Ursache. Schreibst du zusätzlich {0} in die Meldung, erscheint sie in einem vollständigen Bericht zweimal:

#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("ungültiger Port: {0}")] // enthält den Text der Ursache ...
BadPort(#[from] ParseIntError), // ... und das hier ist ebenfalls die Ursache
}

Mit {:#} über anyhow ausgegeben, ergibt das ungültiger Port: invalid digit found in string: invalid digit found in string. Die Lösung: Schreib entweder #[error("ungültiger Port")] und lass die Kette die Ursache zeigen, oder behalte {0} und markiere das Feld nicht als Ursache. Entscheide dich für eins von beiden.

Mit {} ausgeben und die Ursache verlieren. println!("{e}") zeigt bei einem anyhow::Error nur die äußerste Meldung, etwa „Einstellungen konnten nicht geladen werden“. Wenn du einen Fehler für einen Menschen ausgibst, nimm {:#} (eine Zeile) oder {:?} (vollständiger Bericht).

anyhow in der öffentlichen API einer Bibliothek benutzen. Gibt deine Bibliothek anyhow::Result zurück, können Aufrufer nicht mit match auf deine Fehler reagieren. Sie müssten den Typ mit downcast_ref erraten. Gib öffentlichen Funktionen ein thiserror-Enum und heb dir anyhow für Anwendungen, Tests und Beispiele auf.

  • thiserror ist zum Definieren von Fehlern da: #[derive(Error)] plus eine #[error("...")]-Meldung pro Variante ersetzt deine handgeschriebenen Display- und Error-Implementierungen.
  • #[from] sorgt dafür, dass ? einen inneren Fehler in deine Variante umwandelt, und speichert ihn als Ursache. #[source] speichert die Ursache ohne die Umwandlung.
  • anyhow ist zum Weiterreichen von Fehlern da: anyhow::Result<T> nimmt über ? jeden Fehler an.
  • .context() und .with_context() fügen der Fehlerkette eine gut lesbare Schicht hinzu. Die Kette gibst du mit {:#} (eine Zeile) oder {:?} (Bericht) aus.
  • bail! gibt sofort einen Fehler zurück, ensure! gibt einen zurück, wenn eine Bedingung falsch ist, und anyhow! erzeugt einen.
  • Übliche Aufteilung: thiserror im Bibliothekscode, anyhow in main.

Kurzer Check

1. Du schreibst eine Bibliothek, und Aufrufer sollen auf einen Timeout anders reagieren können als auf falsche Eingaben. Welches Crate passt am besten?

2. Was bewirkt #[from] an einem Feld einer Variante?

3. Welches Format gibt einen anyhow::Error mit seiner ganzen Kette in einer Zeile aus?

4. Wann solltest du .with_context(|| ...) statt .context(...) nehmen?

5. Was macht ensure!(amount > 0, "amount muss positiv sein"), wenn amount gleich 0 ist?

Punkte: 0 / 5