Fehlerbehandlung in Rust mit thiserror und anyhow

1. Übersicht

Rust zwingt Sie, Fehler explizit durch Result<T, E> zu behandeln. Dies ist sicher, aber das manuelle Schreiben von Fehlertypen ist mühsam und ausschweifend. Sie müssen Display, Error und From für jeden Fall selbst implementieren.

Dieser Artikel stellt die zwei beliebtesten Crates vor, um dieses Problem zu lösen. thiserror hilft, strukturierte Fehlertypen zu definieren. anyhow hilft, alle Fehler in einem einzigen Typ zu sammeln und zusätzlichen Kontext anzuhängen. Danach werden Sie wissen, welches Crate wo zu verwenden ist, wie man sie kombiniert und wie man Fehler testet.

2. Problem beim manuellen Schreiben von Fehlern

Angenommen, Sie lesen die Menge einer Bestellung aus einer Datei. Es können zwei Fehler auftreten: I/O-Fehler und Parse-Fehler. Ohne eine Bibliothek zu verwenden, müssen Sie folgendes schreiben:

use std::fmt;

#[derive(Debug)]
pub enum OrderError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

impl fmt::Display for OrderError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            OrderError::Io(_) => write!(f, "Bestelldatei konnte nicht gelesen werden"),
            OrderError::Parse(_) => write!(f, "Bestelldaten haben ein falsches Format"),
        }
    }
}

impl std::error::Error for OrderError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            OrderError::Io(e) => Some(e),
            OrderError::Parse(e) => Some(e),
        }
    }
}

impl From<std::io::Error> for OrderError {
    fn from(e: std::io::Error) -> Self { OrderError::Io(e) }
}

impl From<std::num::ParseIntError> for OrderError {
    fn from(e: std::num::ParseIntError) -> Self { OrderError::Parse(e) }
}

Sie benötigen etwa 30 Zeilen Boilerplate für nur zwei Varianten. Jedes Mal, wenn Sie eine Variante hinzufügen, müssen Sie an vier Stellen Änderungen vornehmen. Dies ist genau das, was thiserror für Sie tut.

Eine andere Abkürzung ist die Verwendung von Box<dyn std::error::Error>. Dies ist kompakt, aber es verliert Typinformationen und erlaubt Ihnen nicht, zusätzlichen Kontext anzuhängen. anyhow ist eine bessere Version dieser Abkürzung.

3. Installation

Fügen Sie zwei Abhängigkeiten zu Cargo.toml hinzu:

[dependencies]
thiserror = "2"
anyhow = "1"

Diese beiden Crates sind unabhängig voneinander. Eine Bibliothek benötigt normalerweise nur thiserror. Eine Binärdatei verwendet normalerweise anyhow, manchmal auch beide.

4. thiserror: strukturierte Fehlertypen für Bibliotheken

4.1. Fehler-Enum definieren

Mit thiserror wird der Code aus Abschnitt 2 zu:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum OrderError {
    #[error("Bestellung #{0} nicht gefunden")]
    NotFound(u64),

    #[error("Ungültige Menge: {quantity} (muss > 0 sein)")]
    InvalidQuantity { quantity: i32 },

    #[error("Bestelldatei konnte nicht gelesen werden")]
    Io(#[from] std::io::Error),

    #[error("Bestelldaten haben ein falsches Format")]
    Parse(#[from] std::num::ParseIntError),
}

Jeder Teil hat seine eigene Rolle:

  • #[derive(Error)] generiert die Implementierung von std::error::Error.
  • #[error("...")] generiert Display. {0} bezieht sich auf das erste Feld einer Tuple-Variante. {quantity} bezieht sich auf ein benanntes Feld.
  • #[from] generiert impl From<std::io::Error> for OrderError. Dadurch konvertiert der Operator ? Fehler automatisch. Ein Feld mit #[from] wird auch automatisch zu source().

Schreiben Sie nun eine Funktion, die diesen Fehlertyp verwendet:

use std::fs;

pub fn parse_quantity(raw: &str) -> Result<i32, OrderError> {
    let quantity: i32 = raw.trim().parse()?;
    if quantity <= 0 {
        return Err(OrderError::InvalidQuantity { quantity });
    }
    Ok(quantity)
}

pub fn load_quantity(path: &str) -> Result<i32, OrderError> {
    let content = fs::read_to_string(path)?;
    parse_quantity(&content)
}

Die Funktion fs::read_to_string gibt io::Error zurück. Der Operator ? ruft From auf, um es in OrderError::Io zu konvertieren. Ebenso wird ParseIntError in OrderError::Parse konvertiert. Der Aufrufer kann jede Variante match, um sie separat zu behandeln.

4.2. Fehlerursachenkette mit #[source] durchlaufen

Manchmal möchten Sie den ursprünglichen Fehler behalten, ohne ihn automatisch mit From zu konvertieren. In diesem Fall verwenden Sie #[source] statt #[from]:

#[derive(Debug, Error)]
pub enum InventoryError {
    #[error("Bestand für Produkt {sku} konnte nicht aktualisiert werden")]
    UpdateFailed {
        sku: String,
        #[source]
        cause: std::io::Error,
    },
}

Diese Variante hat ein zusätzliches Feld sku, daher kann From nicht automatisch generiert werden. In diesem Fall deklariert #[source] nur, dass cause die Ursache ist. Sie können die Fehlerursachenkette wie folgt durchlaufen:

use std::error::Error as _;

fn print_chain(err: &dyn std::error::Error) {
    eprintln!("Fehler: {err}");
    let mut current = err.source();
    while let Some(cause) = current {
        eprintln!("  verursacht durch: {cause}");
        current = cause.source();
    }
}

Wenn Sie print_chain mit einem UpdateFailed, das einen “permission denied”-Fehler enthält, aufrufen, sehen Sie:

Fehler: Bestand für Produkt SKU-42 konnte nicht aktualisiert werden
  verursacht durch: Permission denied (os error 13)

4.3. #[error(transparent)]

transparent leitet Display und source() direkt an den inneren Fehler weiter. Sie verwenden es, wenn die Variante nur ein Wrapper ist und keine zusätzlichen Informationen hinzufügt:

#[derive(Debug, Error)]
pub enum PaymentError {
    #[error("Karte abgelehnt")]
    CardDeclined,

    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

Die Variante Other spielt die Rolle des „verbleibenden Fehlers”. Spezifische Fehler, die der Aufrufer behandeln muss, haben ihre eigene Variante. Alle anderen Fehler werden in Other gesammelt.

5. anyhow: ein Fehlertyp für die gesamte Anwendung

Auf der Anwendungsebene müssen Sie selten jeden Fehlertyp match. Normalerweise müssen Sie den Fehler nur protokollieren oder dem Benutzer anzeigen, zusammen mit ausreichend Informationen, um zu wissen, was schief gelaufen ist. anyhow::Error erfüllt genau diese Anforderung.

5.1. anyhow::Result und Kontext

use anyhow::{Context, Result};

fn read_port(path: &str) -> Result<u16> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("Konfigurationsdatei {path} konnte nicht gelesen werden"))?;
    let port: u16 = raw
        .trim()
        .parse()
        .context("Port muss eine Ganzzahl im Bereich 0–65535 sein")?;
    Ok(port)
}

fn main() -> Result<()> {
    let port = read_port("app.conf")?;
    println!("Server läuft auf Port {port}");
    Ok(())
}

Der Code funktioniert wie folgt:

  • anyhow::Result<T> ist ein Alias für Result<T, anyhow::Error>.
  • Jeder Typ, der std::error::Error + Send + Sync + 'static implementiert, kann über ? in anyhow::Error konvertiert werden.
  • .context(...) umhüllt den ursprünglichen Fehler in einer neuen Nachricht, und der ursprüngliche Fehler bleibt als Ursache erhalten.
  • .with_context(|| ...) nimmt einen Closure an. Der Closure wird nur bei einem Fehler ausgeführt, daher vermeiden Sie unnötige format!-Aufrufe auf dem erfolgreichen Weg.

Wenn main Err zurückgibt, weil die Datei nicht existiert, gibt das Programm aus:

Error: Konfigurationsdatei app.conf konnte nicht gelesen werden

Caused by:
    No such file or directory (os error 2)

Es gibt drei Möglichkeiten, einen anyhow::Error zu formatieren:

  • {} druckt nur die äußerste Nachricht.
  • {:#} druckt die ganze Kette in einer Zeile, getrennt durch : .
  • {:?} druckt mehrzeilig wie oben.

Wenn Sie in eine Zeile protokollieren möchten, ist {:#} am bequemsten.

Context funktioniert auch mit Option. Dies ist eine elegante Möglichkeit, None in einen aussagekräftigen Fehler umzuwandeln:

use anyhow::{Context, Result};
use std::collections::HashMap;

fn find_email(customers: &HashMap<u64, String>, id: u64) -> Result<&String> {
    customers.get(&id).with_context(|| format!("Keine Kunde #{id} gefunden"))
}

5.2. bail!, ensure! und anyhow!

Diese drei Makros helfen, Ad-hoc-Fehler zu erstellen, ohne einen Typ zu definieren:

use anyhow::{anyhow, bail, ensure, Result};

fn apply_discount(total: u64, percent: u8) -> Result<u64> {
    ensure!(percent <= 100, "Rabatt-Prozentsatz ungültig: {percent}");
    if total == 0 {
        bail!("Rabatt kann nicht auf eine Bestellung von 0 Dong angewendet werden");
    }
    total
        .checked_sub(total * percent as u64 / 100)
        .ok_or_else(|| anyhow!("Zahlenüberlauf bei der Rabattberechnung"))
}
  • ensure!(cond, ...) gibt einen Fehler zurück, wenn die Bedingung falsch ist.
  • bail!(...) entspricht return Err(anyhow!(...)).
  • anyhow!(...) erstellt einen anyhow::Error-Wert ohne zurückzugeben.

Beispiel: apply_discount(500_000, 120) gibt einen Fehler zurück Rabatt-Prozentsatz ungültig: 120.

5.3. Downcast, wenn Sie Fehler noch kategorisieren müssen

anyhow::Error behält den ursprünglichen Typ bei. Sie können downcast, um einen bestimmten Fall zu behandeln:

fn handle(err: &anyhow::Error) {
    match err.downcast_ref::<OrderError>() {
        Some(OrderError::NotFound(id)) => println!("404-Seite für Bestellung #{id} anzeigen"),
        Some(other) => println!("Bestellungsfehler: {other}"),
        None => println!("Systemfehler: {err:#}"),
    }
}

downcast_ref funktioniert auch, wenn der Fehler mit .context(...) umhüllt wurde. Wenn Sie jedoch häufig downcast, ist das ein Zeichen dafür, dass Sie stattdessen ein thiserror-Enum verwenden sollten.

6. Kombination: thiserror unten, anyhow oben

Eine einfache und effektive Regel:

  • Bibliothek oder Domain-Modul verwenden thiserror. Der Aufrufer muss genau wissen, welcher Fehler aufgetreten ist, um eine Aktion zu entscheiden.
  • Binary, CLI, Handler auf der äußersten Ebene verwenden anyhow. Hier melden Sie hauptsächlich Fehler.
use anyhow::{Context, Result};

fn process_order_file(path: &str) -> Result<()> {
    let quantity = load_quantity(path)
        .with_context(|| format!("Verarbeitung der Bestelldatei {path} fehlgeschlagen"))?;
    println!("Bestellung hat {quantity} Artikel");
    Ok(())
}

Die Funktion load_quantity gibt ein strukturiertes OrderError zurück. Die obere Ebene umhüllt zusätzlichen Kontext über die verarbeitete Datei. Wenn Sie mit einer Datei, die -3 enthält, ausführen, sehen Sie:

Error: Verarbeitung der Bestelldatei orders/1001.txt fehlgeschlagen

Caused by:
    Ungültige Menge: -3 (muss > 0 sein)

7. Tests für Fehlerverhalten

Strukturierte Fehlertypen machen Tests genau. Sie können die richtige Variante überprüfen, anstatt Zeichenketten vage zu vergleichen:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn negative_quantity_returns_invalid_quantity() {
        let err = parse_quantity("-3").unwrap_err();

        assert!(matches!(err, OrderError::InvalidQuantity { quantity: -3 }));
        assert_eq!(err.to_string(), "Ungültige Menge: -3 (muss > 0 sein)");
    }

    #[test]
    fn non_numeric_input_returns_parse_error() {
        let err = parse_quantity("abc").unwrap_err();
        assert!(matches!(err, OrderError::Parse(_)));
    }

    #[test]
    fn missing_config_keeps_io_error_as_root_cause() {
        let err = read_port("/khong/ton/tai.conf").unwrap_err();

        assert_eq!(
            err.to_string(),
            "Konfigurationsdatei /khong/ton/tai.conf konnte nicht gelesen werden"
        );
        assert!(err.root_cause().downcast_ref::<std::io::Error>().is_some());
    }
}

Tests ausführen mit:

cargo test
running 3 tests
test tests::negative_quantity_returns_invalid_quantity ... ok
test tests::non_numeric_input_returns_parse_error ... ok
test tests::missing_config_keeps_io_error_as_root_cause ... ok

test result: ok. 3 passed; 0 failed

Der dritte Test zeigt, dass root_cause() zum innersten Fehler in der Kette geht. Dadurch können Sie überprüfen, dass Kontext den ursprünglichen Fehler nicht verliert.

8. Häufige Fehler und wann man nicht verwenden sollte

Hinweis: Wiederholen Sie nicht die Nachricht des ursprünglichen Fehlers in #[error]. Wenn Sie #[error("io error: {0}")] Io(#[from] io::Error) schreiben, wird die ursprüngliche Nachricht zweimal angezeigt, wenn Sie die ganze Kette mit {:?} oder {:#} drucken. Wählen Sie einen der folgenden Ansätze: {0} einbinden und keine Quelle deklarieren, oder Quelle deklarieren und die Nachricht kurz halten.

Hinweis: Verwenden Sie nicht anyhow::Error in der öffentlichen API einer Bibliothek. Der Aufrufer kann Fehler nicht match und muss downcast erzwingen. Stellen Sie stattdessen ein thiserror-Enum zur Verfügung.

Einige andere Fehler, die Sie treffen können:

  • Zwei Varianten mit demselben #[from]-Quelltyp. Beispiel: Zwei Varianten, die beide io::Error umhüllen, erzeugen zwei identische impl From und verursachen einen Kompilierungsfehler. Verwenden Sie #[source] zusammen mit map_err für mindestens eine Variante.
  • Missbrauch von #[from]. Wenn jeder io::Error zu OrderError::Io wird, verlieren Sie Informationen darüber, welche Operation fehlgeschlagen ist. Varianten mit Kontextfeldern wie UpdateFailed { sku, cause } sind oft nützlicher.
  • Vergessen, dass anyhow::Error nicht std::error::Error implementiert. Es dereferenziert zu dyn Error, implementiert das Trait aber nicht. Sie können es nicht an einer Stelle übergeben, die E: std::error::Error benötigt. Falls nötig, umhüllen Sie es mit einer #[error(transparent)]-Variante.
  • .context(format!(...)) aufrufen. Die Zeichenkette wird formatiert, auch wenn kein Fehler vorliegt. Verwenden Sie .with_context(|| ...).
  • Äußerst leistungsempfindlicher oder no_std-Code. anyhow::Error belegt Heap-Speicher. Bei Hot Paths oder eingebetteten Umgebungen ist ein kompaktes Enum die bessere Wahl.

Wenn Sie einen Backtrace möchten, setzen Sie die Umgebungsvariable RUST_BACKTRACE=1 beim Ausführen. anyhow druckt den Backtrace zusammen mit dem Fehler aus.

9. Fazit

thiserror und anyhow erfüllen zwei unterschiedliche Anforderungen. thiserror entfernt Boilerplate beim Definieren von Fehlertypen und eignet sich für Bibliotheken und Domain-Ebenen, wo der Aufrufer Fehler kategorisieren muss. anyhow sammelt alle Fehler in einem Typ und erlaubt das Anhängen von Kontext in jedem Schritt, geeignet für Binärdateien und die äußerste Ebene.

Sie sollten beide kombinieren: definieren Sie strukturierte Fehler unten, umhüllen Sie Kontext oben. Halten Sie die Meldungen abschließend kurz, wiederholen Sie nicht den ursprünglichen Fehler, und schreiben Sie Tests, die die richtige Variante überprüfen, anstatt nur Zeichenketten zu vergleichen.

rusterror-handlingthiserroranyhow