Az async Rust világában a hibakezelés elsőre ugyanúgy néz ki, mint a szinkron kódban: Result<T, E>, ? operátor, match. De ha egyszer elindítasz egy Tokio task-ot, ami elhal egy panic miatt, rájössz, hogy itt bizony más szabályok érvényesek. Ebben a cikkben végigmegyünk azon, hogy hogyan építs fel egy megbízható hibakezelési stratégiát async Rust kódban.

Miért más az async hibakezelés, mint a szinkron kódban

Szinkron Rustban egy hiba mindig a hívási láncon keresztül, egyenesen fel a stack-en propagálódik. Ha egy függvény panic-ol, az egész szál megszakad, és – hacsak nincs catch_unwind – a program (vagy legalább az adott szál) elhal.

Async kódban ez másképp működik, mert a Future-ök nem egy hívási stack-en futnak, hanem egy executor (pl. Tokio) ütemezi őket. Ha egy tokio::spawn-nal elindított task belül panic-ol, az nem dönti le az egész programot – csak az adott task hal el, és a hozzá tartozó JoinHandle egy hibával tér vissza. Ez egyszerre áldás és átok:

  • Áldás, mert egy hibás task nem viszi el az egész szolgáltatást.
  • Átok, mert ha nem figyelsz oda a JoinHandle eredményére, a panic csendben elnyelődik, és soha nem tudsz meg róla semmit.
Jó tudni

Egy tokio::spawn-nal indított task panic-ja nem propagálódik automatikusan sehova. Ha nem .await-eled és nem ellenőrzöd a JoinHandle-t, a hiba egyszerűen eltűnik a logban.

Ez az egyik legfontosabb mentális modellváltás: szinkron kódban a hiba "felfelé" megy, async kódban gyakran "oldalra" – egy másik taskba, amit külön kell figyelned.

A ? operátor használata async fn-ekben és Future láncokban

Jó hír: a ? operátor pontosan úgy működik async fn-ekben, mint sima függvényekben. Az async fn deszugárolva egy Future-t ad vissza, és a ? a Future kiértékelése közben, nem pedig a Future létrehozásakor fut le.

use std::num::ParseIntError;

async fn read_and_parse(input: &str) -> Result<i32, ParseIntError> {
    let trimmed = input.trim();
    let value: i32 = trimmed.parse()?;
    Ok(value * 2)
}

#[tokio::main]
async fn main() {
    match read_and_parse("  21 ").await {
        Ok(v) => println!("Eredmény: {v}"),
        Err(e) => eprintln!("Hiba a feldolgozás közben: {e}"),
    }
}

A fenti kód semmiben nem különbözik egy szinkron verziótól – ez a ? legszebb tulajdonsága. A bonyodalom akkor kezdődik, amikor több különböző hibatípust kell egyesítened egy async láncban, például amikor egy HTTP kliens hívása, egy JSON parse és egy adatbázis-lekérdezés hibáit is kezelned kell egyetlen függvényben.

Ezt hagyományosan saját hibatípusokkal és a From trait implementálásával oldjuk meg, de ez sok boilerplate-tel jár. Itt jön képbe a thiserror és az anyhow, amikről lejjebb részletesen beszélünk.

Tipp

A ? operátor async blokkokban (async { ... }) is működik, nem csak async fn-ekben – ez hasznos, ha egy tokio::spawn-nak átadott closure-ön belül szeretnél korai visszatérést.

JoinHandle és a task panic-ok kezelése Tokio-ban

Amikor egy tokio::spawn-nal task-ot indítasz, egy JoinHandle<T>-t kapsz vissza. Ennek az .await-elése egy Result<T, JoinError>-t ad – nem a task belső Result-ját közvetlenül, hanem egy plusz réteget, ami a task futásának sikerét vagy sikertelenségét (panic, cancel) jelzi.

Ez azt jelenti, hogy ha a task maga is Result<T, E>-t ad vissza, két szintű hibakezelésre van szükséged:

use tokio::task::JoinError;

#[derive(Debug)]
enum WorkerError {
    Join(JoinError),
    Logic(String),
}

async fn run_worker() -> Result<u32, WorkerError> {
    let handle = tokio::spawn(async {
        // szimulált munka, ami esetleg panic-olhat
        if false {
            panic!("váratlan állapot");
        }
        Ok::<u32, String>(42)
    });

    match handle.await {
        Ok(inner_result) => inner_result.map_err(WorkerError::Logic),
        Err(join_err) => Err(WorkerError::Join(join_err)),
    }
}

#[tokio::main]
async fn main() {
    match run_worker().await {
        Ok(v) => println!("Siker: {v}"),
        Err(e) => eprintln!("Hiba történt: {e:?}"),
    }
}

A JoinError egyébként hordozza magában, hogy a task panic-olt-e (is_panic()), vagy le lett-e mondva (is_cancelled()). Ez elengedhetetlen információ, ha érdemi diagnosztikát akarsz.

Figyelem

Ha egy JoinHandle-t soha nem .await-elsz, és a task panic-ol, a hibáról csak annyit fogsz látni, amit a panic hook (alapesetben stderr-re) kiír. Ez production logban könnyen elveszik – mindig kövesd nyomon a spawn-olt taskok végét, vagy legalább egy tokio::spawn-t wrappelő segédfüggvényben logold a JoinError-t.

anyhow és thiserror gyakorlati alkalmazása async kontextusban

Az anyhow és a thiserror a Rust ökoszisztéma két alappillére a hibakezelésben, és async kódban is pontosan úgy használhatók, mint szinkronban.

  • thiserror: könyvtárkód-hibáknál, ahol pontosan definiált, típusos hibavariánsokra van szükség.
  • anyhow: alkalmazáskódban, ahol nem érdekel a pontos hibatípus, csak az, hogy legyen kontextusod és tudj ?-elni bármit.
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ServiceError {
    #[error("adatbázis hiba: {0}")]
    Database(String),

    #[error("időtúllépés a külső szolgáltatás hívása közben")]
    Timeout,

    #[error("érvénytelen bemenet: {0}")]
    InvalidInput(String),
}

async fn fetch_user(id: u32) -> Result<String, ServiceError> {
    if id == 0 {
        return Err(ServiceError::InvalidInput("id nem lehet 0".into()));
    }
    // ...tényleges async lekérdezés helye...
    Ok(format!("User#{id}"))
}

Az anyhow oldalon a Context trait a legnagyobb barátod, mert lehetővé teszi, hogy minden ?-elt hibához hozzáadj egy emberi olvasásra alkalmas kontextust:

use anyhow::{Context, Result};

async fn load_config(path: &str) -> Result<String> {
    let content = tokio::fs::read_to_string(path)
        .await
        .with_context(|| format!("nem sikerült beolvasni a konfigurációs fájlt: {path}"))?;
    Ok(content)
}
Megjegyzés

Ha egy library-t írsz, amit mások is fognak használni, inkább thiserror-ral definiált, konkrét enumot adj vissza – így a hívó tud match-elni a hibákra. Ha végfelhasználói alkalmazást (pl. egy CLI-t vagy egy szervizt), az anyhow::Result sokkal kevesebb boilerplate-et jelent.

Timeout és lemondás (cancellation) kezelése hibaként

Az async programozás egyik különleges hibaforrása a timeout és a cancellation – ezek szinkron kódban egyáltalán nem léteznek ebben a formában.

Tokio-ban a tokio::time::timeout egy Future-t csomagol be, és ha az adott időn belül nem fejeződik be, egy Elapsed hibát kapsz vissza:

use std::time::Duration;
use tokio::time::timeout;

async fn slow_operation() -> u32 {
    tokio::time::sleep(Duration::from_secs(5)).await;
    100
}

async fn with_timeout() -> Result<u32, tokio::time::error::Elapsed> {
    timeout(Duration::from_secs(2), slow_operation()).await
}

Fontos megérteni, hogy amikor egy timeout "elkap" egy Future-t, az alatta futó munka nem törlődik varázsütésre – egyszerűen a Future-t eldobjuk (drop), ami elindítja a benne lévő erőforrások (fájlkezelők, socketek, Drop implementációk) rendes leállítását. Ha a belső munkádnak van olyan része, amit félbeszakítás esetén is le kell futtatni (pl. egy tranzakció visszavonása), azt explicit módon kell megírnod, például tokio::select!-tel figyelve egy cancellation tokenre.

use tokio::sync::oneshot;

async fn cancellable_task(mut cancel: oneshot::Receiver<()>) -> Result<u32, &'static str> {
    tokio::select! {
        _ = &mut cancel => Err("a task lemondásra került"),
        result = async {
            tokio::time::sleep(std::time::Duration::from_secs(3)).await;
            42
        } => Ok(result),
    }
}
Tipp

A lemondást (cancellation) érdemes explicit Result-ként modellezni (pl. egy dedikált Cancelled variánssal), így ugyanabban a hibakezelési láncban tudod kezelni, mint a többi hibát – nem kell külön ágakat nyitnod rá a kódban.

Esettanulmány: egy axum alapú szolgáltatás robusztus hibakezelése

Nézzünk egy gyakorlati példát: egy axum-alapú HTTP szolgáltatás, ami egy belső hibatípust HTTP válasszá alakít. Ez az IntoResponse trait implementálásával oldható meg elegánsan.

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Router,
};
use thiserror::Error;

#[derive(Error, Debug)]
enum ApiError {
    #[error("nem található")]
    NotFound,

    #[error("időtúllépés")]
    Timeout,

    #[error("belső hiba")]
    Internal(#[from] anyhow::Error),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let status = match &self {
            ApiError::NotFound => StatusCode::NOT_FOUND,
            ApiError::Timeout => StatusCode::GATEWAY_TIMEOUT,
            ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
        };
        (status, self.to_string()).into_response()
    }
}

async fn get_user_handler() -> Result<String, ApiError> {
    let result = tokio::time::timeout(
        std::time::Duration::from_secs(1),
        fetch_from_backend(),
    )
    .await
    .map_err(|_| ApiError::Timeout)?;

    result.map_err(ApiError::Internal)
}

async fn fetch_from_backend() -> Result<String, anyhow::Error> {
    // ...tényleges hívás helye...
    Ok("Kovács Béla".to_string())
}

fn app() -> Router {
    Router::new().route("/user", get(get_user_handler))
}

Ez a mintázat három dolgot old meg egyszerre:

  1. Egyértelmű hibafajták: a thiserror-ral definiált ApiError pontosan leírja a lehetséges kimeneteket.
  2. Timeout mint hiba: a tokio::time::timeout eredményét egyenesen a saját hibatípusunkba mappeljük.
  3. HTTP válasszá alakítás: az IntoResponse implementáció garantálja, hogy soha nem "felejtünk el" hibát kezelni – a fordító rákényszerít, hogy minden variánst lefedjünk.
Jó tudni

Éles rendszerben mindig érdemes egy globális tower middleware-t (pl. egyéni Layer) is beépíteni, ami elkapja azokat a nem várt panic-okat, amik esetleg egy handler belsejében történnek – így egyetlen rossz kérés sem tudja lehúzni az egész workert.

Összefoglalás

Az async hibakezelés Rustban nem bonyolultabb, mint a szinkron verzió – csak több réteget kell egyszerre fejben tartanod: a saját Result típusaidat, a JoinHandle által visszaadott JoinError-t, valamint a timeout és cancellation eseteket, amik önmagukban is hibaforrások. A ? operátor és a Future kombinációja természetes módon illeszkedik ebbe a világba, a thiserror és anyhow pedig segít abban, hogy a kód olvasható és karbantartható maradjon. Ha ezeket a mintázatokat tudatosan alkalmazod – különösen a JoinHandle figyelését és a timeoutok explicit hibaként kezelését –, sokkal ellenállóbb async szolgáltatásokat tudsz építeni, amik nem omlanak össze csendben egy elfelejtett .await miatt.