Miért más az async hibakezelés?

Az async Rust kódban a hibakezelés logikája elvileg megegyezik a szinkron kóddal: a Result<T, E> típus és a ? operátor itt is ugyanúgy működik. A gyakorlatban azonban van néhány dolog, amire figyelni kell:

  • Egy async fn hívási lánc gyakran több réteget fog át (HTTP kliens, adatbázis, üzleti logika), és minden rétegnek lehet saját hibatípusa.
  • A .await pontok miatt a hiba "megjelenési helye" néha távol esik attól a helytől, ahol tényleg keletkezett – ezért fontos a jó kontextus (context) hozzáadása.
  • Sok async ökoszisztéma-crate (HTTP kliensek, adatbázis driverek) saját hibatípust definiál, amit valahogy egységesíteni kell a saját alkalmazásod hibáival.
Megjegyzés

Az async Rust maga nem vezet be új hibakezelési mechanizmust – nincs külön "async Result" vagy hasonló. A Future-ök egyszerűen Result-ot adnak vissza, mint bármelyik szinkron függvény.

A ? operátor async fn-ekben

A ? operátor async függvényekben pontosan úgy viselkedik, mint szinkron kódban: ha a kifejezés Err-t ad vissza, azonnal visszatér a hívó függvényből az adott hibával (miután lefut rajta egy From::from konverzió, ha szükséges). A .await és a ? szépen kombinálható:

use std::fs;
use tokio::time::{sleep, Duration};

async fn read_and_wait(path: &str) -> anyhow::Result<String> {
    let content = fs::read_to_string(path)?; // szinkron hívás, de a ? ugyanúgy működik
    sleep(Duration::from_millis(100)).await;
    Ok(content)
}

A fontos részlet, hogy a ? operátor nem "async-specifikus" – csak azt várja el, hogy a visszatérési típus implementálja a From<E> konverziót a hiba típusára. Ez ugyanaz a mechanizmus, mint szinkron Rustban, csak itt gyakran egy Future belsejében fut le.

Tipp

Ha egy async blokkban (async { ... }) használod a ?-t, a blokk visszatérési típusát a fordító a kontextusból következteti ki. Ha ez nem egyértelmű, adj meg explicit típusannotációt, különben kriptikus hibaüzenetet kaphatsz.

Gyors prototípusozás anyhow-val

Amikor egy alkalmazás vagy CLI eszköz belsejében dolgozol, és nem szeretnél saját hibatípus-hierarchiát tervezni minden apró hívás köré, az anyhow crate a barátod. Az anyhow::Result<T> egy típustörölt hibaértéket (anyhow::Error) tartalmaz, ami bármilyen std::error::Error + Send + Sync + 'static hibát képes befogadni.

use anyhow::{Context, Result};

async fn fetch_config(url: &str) -> Result<String> {
    let resp = reqwest::get(url)
        .await
        .context("nem sikerült elérni a konfig szervert")?;

    let body = resp
        .text()
        .await
        .context("nem sikerült beolvasni a választ")?;

    Ok(body)
}

A .context(...) metódus az anyhow::Context trait-ből származik, és emberi olvasásra alkalmas leírást fűz a hibához, miközben az eredeti hibaláncot (source()) is megtartja. Ez rendkívül hasznos akkor, amikor egy hibaüzenetet később logban vagy hibajegyben kell visszakövetni.

[dependencies]
anyhow = "1.0"
tokio = { version = "1", features = ["full"] }
Tipp

Az anyhow kiváló prototípusokhoz és alkalmazás-szintű (bináris) kódhoz, ahol a hívó fél nem akar (és nem is tud) egyenként pattern-matchelni a hibatípusokra. Könyvtárkód (library crate) esetén viszont jobb, ha konkrét hibatípust adsz vissza – ott jön képbe a thiserror.

thiserror crate egyedi hibatípusok definiálásához

Ha egy library crate-et írsz, vagy szeretnéd, hogy a hívó fél explicit módon tudjon ágazni a hibatípusok szerint (match), akkor érdemes saját enum-ot definiálni. A thiserror crate ezt teszi kényelmessé: a #[derive(Error)] és az attribútumok segítségével gyorsan felírhatod a Display és Error implementációkat kézzel írt boilerplate nélkül.

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("a konfig fájl nem található: {0}")]
    NotFound(String),

    #[error("hibás JSON formátum")]
    ParseError(#[from] serde_json::Error),

    #[error("hálózati hiba a konfig letöltésekor")]
    Network(#[source] reqwest::Error),
}

async fn load_config(path: &str) -> Result<String, ConfigError> {
    let content = std::fs::read_to_string(path)
        .map_err(|_| ConfigError::NotFound(path.to_string()))?;

    // itt csak illusztráció, valóban validálnánk a JSON-t
    serde_json::from_str::<serde_json::Value>(&content)?;

    Ok(content)
}

A #[from] attribútum automatikusan legenerálja a From<serde_json::Error> for ConfigError implementációt, így a ? operátor önmagában el tudja végezni a konverziót – nem kell mindenhol .map_err(...)-t írni.

Jó tudni

A thiserror a hibatípus definiálására szolgál, nem a hibák kezelésére vagy propagálására – az utóbbihoz továbbra is a ? operátort és a From konverziót használod, ahogy azt eddig is tetted szinkron kódban.

Hibák propagálása több async réteg között

A valóságban egy async alkalmazás jellemzően több réteget tartalmaz: adatbázis-réteg, HTTP kliens réteg, üzleti logika, majd a bináris fő belépési pontja. Jó gyakorlat, ha a belső library-rétegek konkrét, thiserror-ral definiált hibatípusokat adnak vissza, míg a legfelső, alkalmazás-szintű réteg anyhow::Result-ba "gyűjti be" ezeket.

use anyhow::{Context, Result};

#[derive(Debug, thiserror::Error)]
pub enum DbError {
    #[error("kapcsolódási hiba az adatbázishoz")]
    Connection,
    #[error("lekérdezési hiba: {0}")]
    Query(String),
}

async fn fetch_user(id: u64) -> Result<String, DbError> {
    if id == 0 {
        return Err(DbError::Connection);
    }
    Ok(format!("user-{id}"))
}

// Az alkalmazás felső rétegében anyhow-ba emeljük a konkrét hibát
async fn handle_request(id: u64) -> Result<()> {
    let user = fetch_user(id)
        .await
        .context("nem sikerült lekérni a felhasználót")?;

    println!("Betöltve: {user}");
    Ok(())
}

Ez a mintázat azért működik szépen, mert az anyhow::Error implementál egy From<E> konverziót minden olyan E-re, ami std::error::Error + Send + Sync + 'static, és a thiserror-ral generált típusok pontosan ilyenek. Így a ? operátor a DbError-t automatikusan anyhow::Error-ré alakítja, a .context() pedig extra emberi leírást fűz hozzá.

Figyelem

Ha egy async fn belsejében több .await pontot is érintő hibalánc van, ügyelj arra, hogy a context() üzeneteid ne legyenek túl általánosak ("hiba történt"). Async kódban a stack trace kevésbé informatív, mint szinkron kódban, ezért a jó kontextus szöveg felér egy mini stack trace-szel.

Érdemes megjegyezni, hogy a let-else szintaxis (ami az 1.65 óta stabil) is jól jöhet, amikor egy Option-ből vagy Result-ból korán akarsz kilépni anélkül, hogy egy egész match blokkot írnál:

async fn parse_id(input: &str) -> anyhow::Result<u64> {
    let Ok(id) = input.parse::<u64>() else {
        anyhow::bail!("érvénytelen azonosító: {input}");
    };

    Ok(id)
}

Az anyhow::bail! makró egy formázott string alapján azonnal Err(anyhow::Error)-t hoz létre és visszatér vele – nagyon kényelmes rövidítés a gyors elágazásokhoz.

Összefoglalás

Az async Rust hibakezelése nem igényel új mentális modellt: a Result, a ? operátor és a From konverzió pontosan úgy dolgozik .await pontok között is, mint szinkron kódban. A gyakorlati kérdés inkább az, hogy melyik réteg milyen típusú hibát adjon vissza. Az anyhow remek választás alkalmazás-szintű, gyorsan iterálható kódhoz, ahol a hibák túlnyomó része csak naplózásra vagy megjelenítésre kerül. A thiserror viszont akkor ér aranyat, amikor library-kódot írsz, és a hívó félnek explicit, pattern-matchelhető hibatípusokra van szüksége. A kettő kombinációja – belül thiserror, kívül anyhow – tapasztalatom szerint a legtöbb valós projektben jól skálázódó, karbantartható megoldás.