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
.awaitpontok 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.
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.
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"] }
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.
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á.
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.