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:
thiserrorhilft dir, Fehlertypen zu definieren. Das Enum schreibst du weiterhin selbst, aber ein#[derive]erzeugtDisplay,ErrorundFromfür dich.anyhowhilft 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.
Bibliotheken und Anwendungen
Abschnitt betitelt „Bibliotheken und Anwendungen“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.
Einrichten
Abschnitt betitelt „Einrichten“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.
thiserror: Error ableiten und Meldungen schreiben
Abschnitt betitelt „thiserror: Error ableiten und Meldungen schreiben“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 gefundennicht genug Guthaben: Kontostand ist 50, abheben wolltest du 80der Betrag muss größer als null seinInsufficientFunds { balance: 50, amount: 80 }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.
thiserror: #[from] und #[source]
Abschnitt betitelt „thiserror: #[from] und #[source]“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 Scopeuse 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: 8080Fehler: fehlendes `=` in Zeile "port 8080"Fehler: der Port ist keine gültige Zahl Ursache: invalid digit found in stringDie 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: 60Zeile 2 enthält keine ZahlUrsache: invalid digit found in stringanyhow: ein Result-Typ für alles
Abschnitt betitelt „anyhow: ein Result-Typ für alles“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.5Fehler: invalid digit found in stringFehler: invalid float literalOhne 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 Meldungformat!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 werdenEinstellungen konnten nicht geladen werden: `99999` ist kein gültiger Port: number too large to fit in target type0: Einstellungen konnten nicht geladen werden1: `99999` ist kein gültiger Port2: number too large to fit in target type„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 typeDie Ü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"))?;anyhow: bail!, ensure! und anyhow!
Abschnitt betitelt „anyhow: bail!, ensure! und anyhow!“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ürreturn 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 seinFehler: nicht genug Guthaben: Kontostand ist 100, abheben wolltest du 250FerrisFehler: kein Benutzer mit der ID 7anyhow::Result aus main zurückgeben
Abschnitt betitelt „anyhow::Result aus main zurückgeben“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(())}Auf stdout wird nichts ausgegeben. Auf stderr siehst du:
Error: "zweiundvierzig" konnte nicht als Zahl gelesen werden
Caused by: invalid digit found in stringDas Error: am Anfang fügt Rust selbst hinzu, wenn main einen Fehler zurückgibt. Deshalb steht es auf Englisch da.
Beide zusammen benutzen
Abschnitt betitelt „Beide zusammen benutzen“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 erledigtFehler: 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: 3Häufige Fehler
Abschnitt betitelt „Häufige Fehler“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 scopehelp: 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.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“- thiserror ist zum Definieren von Fehlern da:
#[derive(Error)]plus eine#[error("...")]-Meldung pro Variante ersetzt deine handgeschriebenenDisplay- undError-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, undanyhow!erzeugt einen.- Übliche Aufteilung: thiserror im Bibliothekscode, anyhow in
main.
Kurzer Check
Abschnitt betitelt „Kurzer Check“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
Weiterlesen
Abschnitt betitelt „Weiterlesen“- anyhow – Dokumentation auf docs.rs (auf Englisch): die vollständige API, auch
Context,chain()und Downcasting. - thiserror – Dokumentation auf docs.rs (auf Englisch): jedes Attribut, zum Beispiel
#[error(transparent)]und Felder für Backtraces. - Der Trait Context – anyhow (auf Englisch): wie
contextundwith_contextfunktionieren, mit Beispielen. - Fehlerbehandlung – Das Rust-Buch: die Grundlagen, auf denen beide Crates aufbauen.
- std::error::Error (auf Englisch): der Standard-Trait hinter
.source()und Fehlerketten.