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
JoinHandleeredményére, a panic csendben elnyelődik, és soha nem tudsz meg róla semmit.
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.
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.
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)
}
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),
}
}
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:
- Egyértelmű hibafajták: a
thiserror-ral definiáltApiErrorpontosan leírja a lehetséges kimeneteket. - Timeout mint hiba: a
tokio::time::timeouteredményét egyenesen a saját hibatípusunkba mappeljük. - HTTP válasszá alakítás: az
IntoResponseimplementá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.
É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.