Amikor valaki átvált szinkron Rustról async kódra, hamar szembesül azzal, hogy a hibakezelés minden korábbi jó szokása továbbra is érvényes — csak épp task-határok, spawn-ok és Future-ök között kell átvezetni. Ez a cikk végigmegy azon, hogyan használd hatékonyan a ? operátort async fn-ekben, hogyan egységesítsd a hibatípusokat az anyhow crate-tel, és hogyan kezeld a panic-ot a hibáktól elkülönítve.

A ? operátor működése async fn-ekben

Az async fn valójában egy Future-t visszaadó függvény, amit a fordító alakít állapotgéppé. A ? operátor pontosan ugyanúgy működik benne, mint egy szinkron fn-ben: ha a kifejezés Err-t ad vissza, azonnal visszatér a hívó felé a hibával, korai return-nel. A lényeg, hogy ez a mechanizmus teljesen független attól, hogy .await-elünk-e valamit előtte vagy sem.

use std::fmt;

#[derive(Debug)]
enum FetchError {
    Network(String),
    Parse(String),
}

impl fmt::Display for FetchError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            FetchError::Network(e) => write!(f, "hálózati hiba: {e}"),
            FetchError::Parse(e) => write!(f, "parse hiba: {e}"),
        }
    }
}

impl std::error::Error for FetchError {}

async fn fetch_and_parse(url: &str) -> Result<u32, FetchError> {
    let body = fake_network_call(url)
        .await
        .map_err(|e| FetchError::Network(e.to_string()))?;

    let value: u32 = body
        .trim()
        .parse()
        .map_err(|_| FetchError::Parse(body.clone()))?;

    Ok(value)
}

async fn fake_network_call(_url: &str) -> Result<String, std::io::Error> {
    Ok("42".to_string())
}

Figyeld meg, hogy a ? az .await után szerepel — ez a szokásos minta. Az .await? kombináció annyit jelent: várd meg a Future-t, majd ha Err, propagáld azonnal.

Tipp

Ha egy async blokkban (async move { ... }) használsz ?-t, a blokk visszatérési típusának is Result-nak kell lennie, egyébként a fordító típushibát dob. Ez gyakori kezdő buktató tokio::spawn belsejében.

Hibatípusok egységesítése az anyhow crate segítségével

A fenti FetchError teljesen rendben van egy kis, önálló modulban, de amint több modulból hívogatod egymást — network, DB, fájlrendszer, szerializáció —, a hibatípusok kombinálása gyorsan fájdalmassá válik. Itt jön képbe az anyhow.

Az anyhow::Error egy típustörlő (type-erased) hibakonténer, ami bármilyen std::error::Error + Send + Sync + 'static implementáló típust be tud csomagolni. Ez ideális application-szintű kódhoz (nem library kódhoz — ott inkább a thiserror-ral definiált saját enumok a jó választás, mert azok megőrzik a típusinformációt a hívó számára).

[dependencies]
tokio = { version = "1", features = ["full"] }
anyhow = "1.0"
use anyhow::{Context, Result};

async fn read_config(path: &str) -> Result<String> {
    let content = tokio::fs::read_to_string(path)
        .await
        .with_context(|| format!("nem sikerült beolvasni a konfigot: {path}"))?;

    Ok(content)
}

async fn parse_port(config: &str) -> Result<u16> {
    let port: u16 = config
        .trim()
        .parse()
        .context("a port érték nem szám")?;

    Ok(port)
}

#[tokio::main]
async fn main() -> Result<()> {
    let config = read_config("port.txt").await?;
    let port = parse_port(&config).await?;

    println!("A szerver a {port} porton indul.");
    Ok(())
}

Az anyhow::Result<T> valójában Result<T, anyhow::Error> alias, így minden ?-operátoros láncolás automatikusan konvertál bármilyen konkrét hibatípust anyhow::Error-ré, amíg az implementálja az Error traitet. A .context() és .with_context() metódusok pedig egy emberi olvasható üzenetet fűznek a hibalánchoz, ami hibakereséskor aranyat ér.

Megjegyzés

A main függvény visszatérési típusaként simán használhatsz anyhow::Result<()>-t — ha a program hibával áll le, az Error: ... formátumban íródik ki, a teljes context-lánccal együtt.

Hibaterjedés több async task között

Amikor tokio::spawn-nal indítasz egy taskot, az visszaad egy JoinHandle<T>-t. Ez a T a task belső Result-ja lehet, de van egy második hibaréteg is: maga a JoinHandle::await is Result-t ad vissza, ami akkor Err, ha a task panickolt vagy megszakadt (cancelled).

Ez azt jelenti, hogy egy taskból induló hibát kétszer kell kezelni: egyszer a task belső logikájában, egyszer pedig a join szinten.

use anyhow::{anyhow, Result};

async fn worker(id: u32) -> Result<u32> {
    if id == 3 {
        return Err(anyhow!("a 3-as worker sosem szeret dolgozni"));
    }
    Ok(id * 10)
}

#[tokio::main]
async fn main() -> Result<()> {
    let mut handles = Vec::new();

    for id in 0..5 {
        handles.push(tokio::spawn(worker(id)));
    }

    for handle in handles {
        match handle.await {
            Ok(Ok(value)) => println!("worker eredmény: {value}"),
            Ok(Err(e)) => eprintln!("worker hiba: {e:#}"),
            Err(join_err) => eprintln!("a task megszakadt vagy panickolt: {join_err}"),
        }
    }

    Ok(())
}

A handle.await típusa itt Result<Result<u32, anyhow::Error>, JoinError> — innen a duplán egymásba ágyazott match. Az {e:#} formázás az anyhow::Error esetén a teljes context-láncot kiírja, nem csak a legfelső üzenetet.

Figyelem

Ha egy tokio::spawn-nal indított taskban panic történik, az alapértelmezett runtime beállítás mellett nem áll le az egész program — a panic elszigetelődik a taskra, és a JoinHandle::await Err(JoinError)-t ad vissza. Ez jó hír robusztusság szempontjából, de könnyű elfelejteni a hívó oldali kezelést.

Ha csatorna (tokio::sync::mpsc) segítségével kommunikálsz taskok között, érdemes a hibát is végigküldeni az üzenettípusban, például egy Result<Msg, anyhow::Error> csatornán, hogy a fogadó oldal döntsön a kezelésről.

Panic és hiba közötti különbség async kontextusban

Fontos élesen elválasztani két fogalmat:

  • Hiba (Result::Err): várható, kezelhető állapot — hálózati timeout, hibás input, fájl nem található. Ezt propagáljuk ?-vel, és a hívó eldönti, mit kezd vele.
  • Panic: programozói hiba vagy helyrehozhatatlan invariáns-sérülés — index out of bounds, unwrap() egy None-on, integer overflow debug módban. Ez nem Result, hanem a végrehajtás megszakítása.

Async kontextusban a panic különösen alattomos tud lenni, mert egy tokio::spawn-nal indított task panicja nem dobja fel magát a hívó .await pontján kivételként, hanem a JoinError-on keresztül jelenik meg, ahogy fentebb láttuk. Ha valaki elfelejti kezelni a JoinHandle eredményét (let _ = tokio::spawn(...)), a panic csendben eltűnik.

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        let v: Vec<i32> = vec![];
        v[0] // ez panickolni fog
    });

    if let Err(e) = handle.await {
        eprintln!("a task panickolt: {e}");
    }
}
Jó tudni

Soha ne használj unwrap()-et vagy expect()-et olyan async kódban, ahol az input külső forrásból (hálózat, felhasználó, fájl) érkezik. Ezek helyén mindig ? és megfelelő hibatípus legyen — a panic legyen fenntartva valóban helyrehozhatatlan, programozói hibás állapotokra.

Best practice-ek production kódhoz

Összefoglalva néhány gyakorlati szabály, amit érdemes követni:

  1. Library kódban használj thiserror-ral definiált konkrét enumokat, hogy a hívó tudjon típus szerint match-elni a hibákra.
  2. Application kódban (binary crate, main.rs) használj anyhow::Result-ot mindenhol, és fűzz .context()-et minden ? elé, ahol az segít a hibakeresésben.
  3. tokio::spawn eredményét mindig kezeld le — vagy .await-elj rá és dolgozd fel a Result<Result<T, E>, JoinError>-t, vagy explicit módon jelezd, ha szándékosan eldobod (let _handle = ... legalább kommenttel).
  4. Loggolj hibát a határon, ne minden szinten — ha egy hiba végigfut több híváson ?-vel, elég egyetlen helyen (pl. a HTTP handler tetején) logolni a teljes context-láncot {:#}-vel.
  5. Ne keverd a panicot és a hibát — ha egy függvény visszatérési típusa Result, sose unwrap()-elj benne olyan értéket, ami külső inputból származik.
  6. Cancel-safe kód írása — async függvényekben legyél tudatában annak, hogy egy .await pont megszakadhat (pl. tokio::select! miatt), ezért kerüld a félbehagyott állapotmódosítást a hiba-útvonalakon.
use anyhow::{Context, Result};

async fn handle_request(id: u32) -> Result<String> {
    let data = fetch_from_db(id)
        .await
        .with_context(|| format!("DB lekérdezés sikertelen id={id}"))?;

    Ok(format!("Feldolgozva: {data}"))
}

async fn fetch_from_db(_id: u32) -> Result<String, std::io::Error> {
    Ok("minta adat".to_string())
}

Összefoglalás

Az async Rust hibakezelése nem bonyolultabb a szinkronnál, csak több réteget kell egyszerre fejben tartani: a ? operátor logikája változatlan, de a tokio::spawn és a JoinHandle egy extra hibaszintet vezet be. Az anyhow crate remekül old meg minden application-szintű hibatípus-egyesítést, míg a panic és a hiba közti határvonalat tudatosan kell meghúznod — a Result a várt, kezelhető állapotokra való, a panic pedig a valóban helyrehozhatatlan hibákra. Ha ezeket a mintákat követed, a production async Rust kódod sokkal átláthatóbb és karbantarthatóbb lesz.