مدیریت خطا در Async در Rust؛ از یک تابع ساده تا یک Health Monitor واقعی
وقتی سراغ دنیای async تو Rust میرویم، ظاهر قضیه خیلی جذاب است: async fn، await، tokio، performance بهتر، عدم بلاک شدن thread اصلی و…
اما واقعیت این است که چیزی که در عمل زودتر از همه اعصاب را خرد میکند، مدیریت خطا است، نه خود async.
در برنامههای همزمان (sync)، اگر جایی همهچیز خراب شود، معمولاً:
- یک stack trace قابل قبول داریم،
- میدانیم تقریباً از کجا به کجا آمدهایم،
- و با کمی جستوجو میتوانیم ریشهٔ مشکل را پیدا کنیم.
در مقابل، در محیط async، بهخاطر lazy بودن Futureها، جابهجایی آنها بین threadها و نحوهٔ poll شدنشان، تکیه کردن به stack trace دیگر آنقدر هم اطمینانبخش نیست. اگر برای خطاها context نسازیم، معمولاً به پیامهایی شبیه این میرسیم:
Error: request failed
یعنی چه درخواستی؟ به کجا؟ در کدام تابع؟ چرا؟ هیچکدام معلوم نیست.
این نوشته سعی میکند مسیر مشخصی ارائه کند:
از یک تابع ساده به نام fetch_status شروع میکنیم، بعد چند الگوی رایج برای مدیریت خطا در async را مرور میکنیم، و در نهایت همهٔ اینها را در قالب یک ابزار واقعی به نام leen-health کنار هم میگذاریم؛ ابزاری که چند endpoint را چک میکند، نتیجه را روی ترمینال و بهصورت JSON بیرون میدهد و میتواند پایهٔ یک health monitor ساده باشد.
چرا مدیریت خطا در async سختتر است؟
سه نکتهٔ مهم باعث میشود مدیریت خطا در async با sync فرق جدی داشته باشد.
۱. stack trace دیگر قهرمان اصلی نیست
در دنیای sync، stack trace بخش بزرگی از دیباگ است. در async اما Futureها در طول عمرشان ممکن است روی threadهای مختلف poll شوند. جریان منطقی کدی که در ذهن میبینیم، همیشه با آنچه در runtime اتفاق میافتد یکی نیست. برای همین، تکیهٔ کامل به stack trace اغلب ناامیدکننده است.
در چنین شرایطی، اگر پیام خطا فقط یک جملهٔ مبهم مثل «request failed» باشد، عملاً اطلاعات مفیدی نداریم. راهحل این است که خودمان در زمان تولید خطا، به آن context اضافه کنیم: URL، نام endpoint، نام تابع، نوع عملیات و هر چیزی که debug را سادهتر میکند.
۲. تسکهای یتیم و خطاهای گمشده
در async خیلی ساده میشود یک task را spawn کرد و بعد دیگر هیچوقت به آن برنگشت. اگر این task خطا بدهد یا حتی panic کند، ممکن است فقط یک لاگ مبهم روی stderr ببینیم و تمام. نه کسی منتظر نتیجهٔ آن task است، نه جایی بهصورت ساختارمند ثبت شده که چه اتفاقی افتاده است.
اینجا یک قانون ساده ولی مهم داریم:
- یا task را collect / join میکنیم،
- یا آگاهانه تصمیم میگیریم که fire-and-forget است و واقعاً اهمیتی ندارد.
هر چیزی بین این دو حالت، در عمل یعنی «خطا را گم میکنم و بعداً اذیت میشوم».
۳. panic در async بیشتر نشانهٔ باگ است، نه خطای عادی
در کار با Arc، Mutex، channelها و state مشترک، panic میتواند کل runtime را در وضعیت بدی قرار دهد. در محیط شبکه و IO، خطاها طبیعی هستند و باید با Result مدیریت شوند: timeout، قطعشدن ارتباط، status code غیرمنتظره و… .
در مقابل، panic باید برای جاهایی بماند که واقعاً invariant شکسته شده است، مثلاً وقتی فرض منطقی درونی برنامه نقض میشود. به عبارت دیگر:
- خطاهای قابل بازیابی →
Result - خطاهای ناشی از باگ منطقی → panic
یک چکلیست کوچک قبل از هر async fn
قبل از اینکه یک تابع async بنویسیم، چند سؤال ساده ولی مهم وجود دارد:
-
این تابع چه نوع خطاهایی تولید میکند؟
- خطاهای موقتی مثل timeout، HTTP 500، DNS error → قابل مدیریت با retry، backoff و…
- خطاهای ناشی از schema اشتباه، serialization خراب، یا منطق غلط خودمان → بیشتر شبیه باگ هستند تا خطاهای عادی.
-
این خطا را چه کسی میبیند؟
- توسعهدهنده در محیط توسعه؟
- تیم SRE در production؟
- یک سرویس دیگر یا bot در Slack؟ لحن و جزئیات پیام خطا باید با مخاطبش هماهنگ باشد.
-
امضای تابع چه باشد؟
Result<T, anyhow::Error>شروع خوبی است.- در سیستمهای بزرگتر، یک error type اختصاصی با
thiserrorقابل ترجیح است.
-
آیا برای خطا context اضافه میکنیم؟
- بدون
.context()و.with_context()در async معمولاً در بهترین حالت فقط حدس میزنیم چه شده است.
- بدون
ساختن آجر پایه: تابع fetch_status
برای اینکه بحث از حالت تئوری خارج شود، یک تابع ساده طراحی میکنیم که فقط status code یک URL را برمیگرداند، اما مدیریت خطا در آن «درست» انجام شده است:
use anyhow::{Context, Result};
use reqwest::Client;
pub async fn fetch_status(url: &str) -> Result<u16> {
let client = Client::builder()
.user_agent("drunkleen-async/0.1")
.build()
.context("failed to build HTTP client")?;
let response = client
.get(url)
.send()
.await
.with_context(|| format!("request to {url} failed"))?;
// Expose only the HTTP status so the caller decides what to do.
Ok(response.status().as_u16())
}
#[tokio::main]
async fn main() -> Result<()> {
let url = "https://drunkleen.com";
let status = fetch_status(url).await?;
println!("status for {url}: {}", status);
Ok(())
}
چند نکتهٔ ساده ولی مهم در همین قطعهٔ کوچک وجود دارد:
- با
.context("failed to build HTTP client")اگر ساخت client به هر دلیلی شکست بخورد، دلیل آن در متن خطا مشخص است. - با
.with_context(|| format!("request to {url} failed"))اگر ارسال درخواست یا دریافت پاسخ خطا بدهد، دقیقاً میدانیم برای چه URLای بوده است. - تابع فقط status code را برمیگرداند، نه کل
Responseرا. تصمیمگیری دربارهٔ اینکه کدام status خوب است و کدام بد، به لایهٔ بالاتر سپرده میشود.
وقتی یک نوع خطای معنایی نیاز داریم: thiserror
کتابخانهٔ anyhow برای جمعکردن خطاها در سطح بالا بسیار راحت است، مخصوصاً برای ابزارهای خط فرمانی و prototypeها. اما زمانی که بخواهیم:
- روی نوع خطا در metrics برچسب بزنیم،
- در UI بر اساس نوع خطا واکنشهای متفاوت داشته باشیم،
- یا logic retry را فقط برای بعضی خطاها فعال کنیم،
آنوقت یک error type معنایی لازم داریم. برای مثال:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MonitorError {
#[error("network issue: {0}")]
Network(#[from] reqwest::Error),
#[error("timed out after {0:?}")]
Timeout(std::time::Duration),
#[error("unexpected status {status} for {url}")]
UnexpectedStatus { status: u16, url: String },
}
این ساختار چند مزیت دارد:
- میتوانیم در متریکها labelهایی مانند
kind="timeout"یاkind="unexpected_status"داشته باشیم. - در لاگها پیامهای دقیق و قابل جستوجو تولید میشود.
- تصمیمگیری بر اساس نوع خطا با یک
matchساده رویMonitorErrorانجام میشود.
وابستگیهای پایه برای کار با async و مدیریت خطا
برای کار با مثالها و الگوهایی که در ادامه میآیند، پیکربندی زیر در Cargo.toml کافی است:
[package]
name = "async-errors"
edition = "2024"
[dependencies]
anyhow = "1"
thiserror = "1"
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
در این تنظیمات:
- از
rustls-tlsبهجای OpenSSL استفاده شده است تا build سادهتر باشد. tokioباmacros(برای#[tokio::main])،rt-multi-thread(برای runtime چندریسمانی) وtime(برای امکاناتی مثلsleepوtimeout) فعال شده است.
چند الگوی عملی برای مدیریت خطا در async
تا اینجا ذهنیت و زیرساخت را آماده کردهایم. حالا چند الگوی رایج را مرور میکنیم که در سرویسهای واقعی بسیار پرکاربرد هستند.
تجمیع نتایج با JoinSet
وقتی چند endpoint را همزمان چک میکنیم، میخواهیم:
- taskها یتیم نشوند؛
- برای هر task timeout در نظر بگیریم؛
- و بتوانیم بین خطای timeout، خطای منطقی و panic تفاوت بگذاریم.
الگوی زیر این نیازها را پوشش میدهد:
use anyhow::{Context, Result};
use tokio::{
task::JoinSet,
time::{timeout, Duration},
};
pub async fn gather(urls: &[String]) -> Result<Vec<u16>> {
let mut set = JoinSet::new();
for url in urls.iter().cloned() {
set.spawn(async move {
let status = timeout(Duration::from_secs(3), crate::fetch_status(&url))
.await
.context("task aborted because of timeout")??;
Ok::<u16, anyhow::Error>(status)
});
}
let mut statuses = Vec::with_capacity(urls.len());
while let Some(res) = set.join_next().await {
match res {
Ok(Ok(code)) => statuses.push(code),
Ok(Err(err)) => eprintln!("worker failed: {err:#}"),
Err(join_err) => eprintln!("task panicked: {join_err}"),
}
}
Ok(statuses)
}
استفاده از JoinSet باعث میشود همهٔ taskها زیر یک چتر کنترل شوند و خطاها بهصورت دو لایهای (join error در برابر خطای منطقی تابع) مدیریت شوند.
گوش دادن به هشدار بیرونی با select!
گاهی لازم است حین انجام یک عملیات async، به یک سیگنال بیرونی هم گوش کنیم؛ مثلاً یک سیستم مانیتورینگ که میگوید عملیات باید متوقف شود. الگوی زیر ترکیب select! و mpsc را نشان میدهد:
use anyhow::Result;
use tokio::{select, sync::mpsc};
pub async fn watch_with_alerts(url: String) -> Result<()> {
let (tx, mut rx) = mpsc::channel(4);
let worker = tokio::spawn(async move {
fetch_status(&url).await?;
Ok::<_, anyhow::Error>(())
});
select! {
biased;
res = worker => {
res??;
}
msg = rx.recv() => {
if let Some(alert) = msg {
anyhow::bail!("alert channel said: {alert}");
}
}
}
Ok(())
}
در این حالت، تابع یا منتظر اتمام worker میماند، یا اگر هشدار برسد، همان لحظه با یک خطای معنیدار خارج میشود.
retry با backoff نمایی
در ارتباطات شبکهای، retry یکی از ابزارهای اصلی است، اما باید با تأخیر و تعداد تلاش معقول همراه باشد:
use tokio::time::{sleep, Duration};
use anyhow::Result;
pub async fn retry_fetch_status(url: &str) -> Result<u16> {
let mut attempts = 0;
let mut delay = Duration::from_millis(200);
loop {
match fetch_status(url).await {
Ok(code) => return Ok(code),
Err(err) if attempts < 3 => {
eprintln!(
"attempt={} url={} failed: {err:#}; retrying in {:?}",
attempts + 1,
url,
delay
);
sleep(delay).await;
delay *= 2;
attempts += 1;
}
Err(err) => return Err(err),
}
}
}
این الگو از retry بیپایان جلوگیری میکند و به سرویس مقابل نیز فرصت بازیابی میدهد.
ساختارمند کردن لاگها با tracing
برای اینکه در دنیای async بتوانیم رفتار سیستم را خوب ببینیم، داشتن لاگهای ساده و متنی کافی نیست. tracing امکان تولید لاگهای ساختارمند را فراهم میکند:
use tracing::{error, info};
use tracing_subscriber::{fmt, EnvFilter};
pub fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into());
fmt().with_env_filter(filter).init();
}
و برای یک تابع:
use anyhow::Result;
pub async fn monitored_fetch(url: &str) -> Result<u16> {
info!(target = "monitor", %url, "starting fetch");
match fetch_status(url).await {
Ok(code) => {
info!(target = "monitor", %url, code, "fetch completed");
Ok(code)
}
Err(err) => {
error!(target = "monitor", %url, error = %err, "fetch failed");
Err(err)
}
}
}
با این ساختار میتوان روی داشبوردها دقیقاً دید کدام endpoint چند بار و با چه نتیجهای فراخوانی شده است.
ساختن یک Health Monitor واقعی: leen-health
تمام مفاهیم بالا را میتوان در یک ابزار کوچک ولی کاربردی کنار هم گذاشت. ایدهٔ leen-health این است:
- یک فایل پیکربندی JSON داشته باشیم که در آن چند endpoint تعریف شده است؛
- برای هر endpoint با سیاست مشخص (timeout، retry، لاگ) یک درخواست async ارسال کنیم؛
- نتیجهٔ هر endpoint را ثبت کنیم؛
- در نهایت، خلاصهٔ نتایج را روی ترمینال و بهصورت JSON چاپ کنیم.
پیکربندی پروژه
Cargo.toml:
[package]
name = "leen-health"
edition = "2024"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
ساختار فایلها:
src/
├── main.rs
├── monitor.rs
└── config.rs
config.rs: بارگذاری پیکربندی
use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct Endpoint {
pub name: String,
pub url: String,
pub expected_status: u16,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub endpoints: Vec<Endpoint>,
}
impl Config {
pub fn load(path: &str) -> Result<Self> {
let data = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {path}"))?;
Ok(serde_json::from_str(&data)
.with_context(|| format!("{path} is not valid JSON"))?)
}
}
main.rs: نقطهٔ ورود و راهاندازی
mod config;
mod monitor;
use anyhow::Result;
use clap::Parser;
use config::Config;
use monitor::run_monitor;
use tracing_subscriber::{fmt, EnvFilter};
#[derive(Debug, Parser)]
struct Cli {
#[arg(short, long, default_value = "endpoints.json")]
config: String,
#[arg(long, default_value_t = 3)]
retries: u8,
}
#[tokio::main]
async fn main() -> Result<()> {
fmt().with_env_filter(EnvFilter::from_default_env()).init();
let cli = Cli::parse();
let cfg = Config::load(&cli.config)?;
run_monitor(cfg, cli.retries).await
}
monitor.rs: اجرای پایش
use anyhow::{Context, Result};
use reqwest::Client;
use serde::Serialize;
use std::time::Duration;
use tokio::{
sync::mpsc,
task::JoinSet,
time::{sleep, timeout},
};
use tracing::{error, info, warn};
#[derive(Debug, Serialize)]
struct Report {
name: String,
status: String,
code: Option<u16>,
message: Option<String>,
}
pub async fn run_monitor(config: crate::config::Config, retries: u8) -> Result<()> {
let client = Client::builder()
.user_agent("drunkleen-health/0.1")
.build()
.context("failed to build reqwest client")?;
let (tx, mut rx) = mpsc::channel::<Report>(16);
let mut set = JoinSet::new();
for endpoint in config.endpoints {
let tx = tx.clone();
let client = client.clone();
set.spawn(async move {
let mut attempt = 0;
let mut delay = Duration::from_millis(400);
loop {
match timeout(Duration::from_secs(4), client.get(&endpoint.url).send()).await {
Err(_) => {
warn!(target = "monitor", name = %endpoint.name, "request timed out");
if attempt >= retries.into() {
tx.send(Report {
name: endpoint.name.clone(),
status: "timeout".into(),
code: None,
message: Some("request exceeded 4s".into()),
})
.await
.context("failed to send timeout report")?;
break;
}
}
Ok(Err(err)) => {
error!(target = "monitor", name = %endpoint.name, %err, "network error");
tx.send(Report {
name: endpoint.name.clone(),
status: "network_error".into(),
code: None,
message: Some(err.to_string()),
})
.await
.context("failed to send network report")?;
break;
}
Ok(Ok(response)) => {
let status = response.status().as_u16();
if status == endpoint.expected_status {
tx.send(Report {
name: endpoint.name.clone(),
status: "ok".into(),
code: Some(status),
message: None,
})
.await
.context("failed to send ok report")?;
} else {
tx.send(Report {
name: endpoint.name.clone(),
status: "mismatch".into(),
code: Some(status),
message: Some(format!(
"expected {}, got {}",
endpoint.expected_status, status
)),
})
.await
.context("failed to send mismatch report")?;
}
break;
}
}
attempt += 1;
sleep(delay).await;
delay *= 2;
}
Ok::<_, anyhow::Error>(())
});
}
drop(tx);
let mut reports = Vec::new();
while let Some(report) = rx.recv().await {
info!(
target = "monitor",
name = %report.name,
status = %report.status,
"report received"
);
println!("{} => {}", report.name, report.status);
reports.push(report);
}
while let Some(res) = set.join_next().await {
res??;
}
println!("\nJSON summary:\n{}", serde_json::to_string_pretty(&reports)?);
Ok(())
}
نمونهٔ فایل پیکربندی
{
"endpoints": [
{ "name": "fast", "url": "https://httpbin.org/status/200", "expected_status": 200 },
{ "name": "slow", "url": "https://httpbin.org/delay/5", "expected_status": 200 },
{ "name": "broken", "url": "https://httpbin.org/status/503", "expected_status": 200 }
]
}
با اجرای دستور:
cargo run -- --config endpoints.json --retries 2
خروجی چیزی شبیه این خواهد بود:
fast => ok
slow => timeout
broken => mismatch
JSON summary:
[
{
"name": "fast",
"status": "ok",
"code": 200,
"message": null
},
{
"name": "slow",
"status": "timeout",
"code": null,
"message": "request exceeded 4s"
},
{
"name": "broken",
"status": "mismatch",
"code": 503,
"message": "expected 200, got 503"
}
]
در یک نگاه، وضعیت هر endpoint، نوع مشکل و پیام مربوط به آن مشخص است.
جمعبندی
مدیریت خطا در async اگر ساده گرفته شود، خیلی سریع خودش را در تولید، لاگهای مبهم و رفتارهای غیرقابل پیشبینی نشان میدهد. ایدهٔ اصلی این متن این بود که:
- بهجای تکیهٔ صرف بر stack trace، برای خطاها context بسازیم؛
- panic را برای باگهای واقعی نگه داریم و خطاهای معمول را با
Resultمدیریت کنیم؛ - از ابزارهایی مثل
anyhow،thiserror،JoinSet،select!، retry با backoff وtracingبرای ساختن رفتاری قابل پیشبینی استفاده کنیم؛ - و در نهایت، این الگوها را در قالب یک ابزار واقعی مثل
leen-healthبههم وصل کنیم.
همین الگوها را میتوان در سیستمهای بزرگتر و پیچیدهتر هم بهکار گرفت. هدف این است که وقتی خطا اتفاق میافتد، بهجای حدس زدن و جستوجوی کورکورانه در لاگها، بتوانیم با چند نگاه به خروجی و لاگها بفهمیم چه شده، کجا، چرا، و در صورت نیاز، چطور آن را برطرف کنیم.
دیدگاهها
اولین نفری باشید که دیدگاه میگذارد.