Lewati ke konten

Penanganan Error di Rust dengan anyhow dan thiserror

Di halaman Result dan Error kustom, kamu menulis tipe error secara manual: sebuah enum, sebuah impl Display, dan mungkin impl From supaya ? bisa dipakai. Itu cara yang tepat untuk belajar. Tapi di proyek sungguhan, kamu akan menulis kode boilerplate (kode berulang) yang sama terus-menerus.

Ada dua crate (pustaka Rust) kecil, buatan penulis yang sama, yang menghilangkan boilerplate itu. Keduanya menyelesaikan dua masalah yang berbeda:

  • thiserror membantumu mendefinisikan tipe error. Kamu tetap menulis enum-nya, tapi sebuah #[derive] akan menuliskan Display, Error, dan From untukmu.
  • anyhow membantumu meneruskan error. Crate ini memberimu satu tipe error yang bisa menampung error apa saja, plus cara mudah untuk menambahkan pesan seperti “saat memuat file konfigurasi”.

Pilihanmu bergantung pada siapa yang menangani error-nya.

Library adalah kode yang dipanggil orang lain. Pemanggilnya mungkin ingin bereaksi berbeda untuk setiap kegagalan: mencoba lagi saat timeout, menampilkan pesan error di formulir saat input salah. Jadi library sebaiknya mengembalikan enum yang presisi, yang bisa di-match oleh pemanggilnya. Itulah thiserror.

Aplikasi (main.rs milikmu) biasanya adalah perhentian terakhir. Sebagian besar error berakhir sebagai pesan untuk manusia, dan mungkin sebuah exit code. Kamu jarang men-match error-nya; kamu ingin error itu mudah diteruskan ke atas dengan ? dan mudah dibaca. Itulah anyhow.

Patokan yang bagus: thiserror untuk kode yang dipanggil orang lain, anyhow untuk lapisan teratas programmu. Banyak proyek memakai keduanya, seperti yang akan kamu lihat di akhir halaman ini.

Terminal window
cargo add thiserror anyhow
[dependencies]
anyhow = "1"
thiserror = "2"

Baru kenal Cargo.toml? Lihat Cargo dan Crate. Kedua crate ini tersedia di Rust Playground, jadi setiap tombol “Try it Yourself” di halaman ini bisa dipakai.

thiserror: derive Error dan tulis pesannya

Section titled “thiserror: derive Error dan tulis pesannya”

Pasang #[derive(Debug, Error)] pada enum-mu, lalu beri setiap varian pesan #[error("...")]. Pesan itu menjadi teks Display untuk varian tersebut. Di dalamnya, {0} berarti field tuple pertama, dan {balance} berarti field bernama:

use thiserror::Error;
#[derive(Debug, Error)]
enum BankError {
#[error("akun `{0}` tidak ditemukan")]
NotFound(String),
#[error("saldo tidak cukup: saldo {balance}, mencoba mengambil {amount}")]
InsufficientFunds { balance: u32, amount: u32 },
#[error("jumlah harus lebih dari nol")]
ZeroAmount,
}
fn main() {
let errors = vec![
BankError::NotFound(String::from("alice")),
BankError::InsufficientFunds { balance: 50, amount: 80 },
BankError::ZeroAmount,
];
for e in &errors {
println!("{e}");
}
println!("{:?}", errors[1]);
}

Output:

akun `alice` tidak ditemukan
saldo tidak cukup: saldo 50, mencoba mengambil 80
jumlah harus lebih dari nol
InsufficientFunds { balance: 50, amount: 80 }
▶ Coba sendiri

Ini persis enum dari halaman Error kustom, tapi seluruh blok impl Display sudah hilang. Tidak ada yang disembunyikan saat runtime: thiserror hanya menulis kode yang sebenarnya akan kamu tulis sendiri. Pemanggil tetap melihat enum biasa.

Error sering punya penyebab: “port tidak valid” karena “invalid digit found in string”. Trait Error standar punya method .source() untuk ini, dan thiserror mengisinya untukmu.

#[from] melakukan dua hal. Ia menandai field itu sebagai source (sumber error), dan ia menulis impl From, sehingga ? otomatis mengubah error di dalamnya menjadi varianmu:

use std::error::Error; // membawa method .source() ke dalam scope
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
enum ConfigError {
#[error("tidak ada `=` di baris {0:?}")]
NoEquals(String),
#[error("port bukan angka yang valid")]
BadPort(#[from] ParseIntError),
}
fn parse_port(line: &str) -> Result<u16, ConfigError> {
let (_, value) = line
.split_once('=')
.ok_or_else(|| ConfigError::NoEquals(line.to_string()))?;
let port = value.trim().parse::<u16>()?; // ParseIntError berubah menjadi ConfigError::BadPort
Ok(port)
}
fn main() {
for line in ["port = 8080", "port 8080", "port = delapan"] {
match parse_port(line) {
Ok(port) => println!("ok: {port}"),
Err(e) => {
println!("error: {e}");
if let Some(cause) = e.source() {
println!(" disebabkan oleh: {cause}");
}
}
}
}
}

Output:

ok: 8080
error: tidak ada `=` di baris "port 8080"
error: port bukan angka yang valid
disebabkan oleh: invalid digit found in string
▶ Coba sendiri

“invalid digit found in string” adalah pesan bawaan Rust untuk ParseIntError, jadi tetap dalam bahasa Inggris. Artinya: ada karakter yang bukan digit di dalam string.

Kadang kamu ingin punya source tapi tanpa From otomatis, karena kamu perlu menambahkan data lain, misalnya nomor baris. Kalau begitu, pakai #[source] pada field-nya, dan buat error-nya sendiri dengan .map_err(). (Field yang namanya persis source otomatis dianggap sebagai source.) thiserror juga bisa dipakai pada struct, bukan hanya enum:

use std::error::Error;
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
#[error("baris {line} tidak berisi angka")]
struct LineError {
line: usize,
#[source]
cause: ParseIntError,
}
fn sum_lines(text: &str) -> Result<i32, LineError> {
let mut total = 0;
for (i, row) in text.lines().enumerate() {
let n: i32 = row
.trim()
.parse()
.map_err(|cause| LineError { line: i + 1, cause })?;
total += n;
}
Ok(total)
}
fn main() {
println!("total: {}", sum_lines("10\n20\n30").unwrap());
let err = sum_lines("10\ndua puluh\n30").unwrap_err();
println!("{err}");
println!("disebabkan oleh: {}", err.source().unwrap());
}

Output:

total: 60
baris 2 tidak berisi angka
disebabkan oleh: invalid digit found in string
▶ Coba sendiri

anyhow::Result<T> adalah singkatan dari Result<T, anyhow::Error>. Sebuah anyhow::Error bisa menampung tipe error apa pun yang mengimplementasikan trait Error standar. Jadi ? bisa dipakai untuk semuanya, bahkan saat satu fungsi menemui beberapa tipe error yang berbeda:

use anyhow::Result;
// Dua tipe error yang berbeda: ParseIntError dan ParseFloatError.
// anyhow::Result menerima keduanya lewat `?`.
fn total_price(quantity: &str, price: &str) -> Result<f64> {
let quantity: u32 = quantity.trim().parse()?;
let price: f64 = price.trim().parse()?;
Ok(quantity as f64 * price)
}
fn main() {
for (quantity, price) in [("3", "2.50"), ("tiga", "2.50"), ("3", "murah")] {
match total_price(quantity, price) {
Ok(total) => println!("total: {total}"),
Err(e) => println!("error: {e}"),
}
}
}

Output:

total: 7.5
error: invalid digit found in string
error: invalid float literal
▶ Coba sendiri

Tanpa anyhow, kamu butuh enum sendiri dengan dua varian, atau Box<dyn Error>. anyhow itu seperti Box<dyn Error> yang lebih nyaman. Tapi perhatikan masalahnya: “invalid digit found in string” tidak memberi tahu nilai mana yang salah. Untuk itulah ada context.

anyhow: menambahkan context dan mencetak rantai error

Section titled “anyhow: menambahkan context dan mencetak rantai error”

Impor trait Context, dan setiap Result mendapat dua method baru:

  • .context("pesan") membungkus error dengan pesan yang tetap.
  • .with_context(|| format!(...)) menerima closure. Pesannya hanya dibuat kalau memang terjadi error, jadi pakai ini kalau pesannya butuh format!.

Setiap lapisan context menambah satu mata rantai pada error chain (rantai error). Cara kamu mencetak error menentukan seberapa banyak rantai yang terlihat:

use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> {
let port: u16 = text
.parse()
.with_context(|| format!("`{text}` bukan port yang valid"))?;
Ok(port)
}
fn load_settings(port_text: &str) -> Result<u16> {
let port = parse_port(port_text).context("gagal memuat pengaturan")?;
Ok(port)
}
fn main() {
let err = load_settings("99999").unwrap_err();
println!("{err}"); // hanya pesan paling luar
println!("{err:#}"); // seluruh rantai dalam satu baris
for (i, cause) in err.chain().enumerate() {
println!("{i}: {cause}");
}
}

Output:

gagal memuat pengaturan
gagal memuat pengaturan: `99999` bukan port yang valid: number too large to fit in target type
0: gagal memuat pengaturan
1: `99999` bukan port yang valid
2: number too large to fit in target type
▶ Coba sendiri

“number too large to fit in target type” adalah pesan Rust sendiri: angkanya terlalu besar untuk tipe tujuannya (u16 maksimal 65535).

Format ketiga adalah {:?}. Format ini mencetak laporan beberapa baris, dan itulah yang kamu lihat saat main mengembalikan error:

println!("{err:?}");
gagal memuat pengaturan
Caused by:
0: `99999` bukan port yang valid
1: number too large to fit in target type

Judul “Caused by:” (disebabkan oleh) ditulis oleh anyhow sendiri, jadi selalu dalam bahasa Inggris.

Kalau environment variable RUST_BACKTRACE=1 di-set, {:?} juga menambahkan stack backtrace yang menunjukkan di mana error itu dibuat. Lihat Environment dan proses.

Contoh pemakaian nyata yang umum adalah membaca file, karena pesan dari sistem operasi (“No such file or directory”) tidak pernah menyebutkan file mana:

let text = std::fs::read_to_string(path)
.with_context(|| format!("gagal membaca file konfigurasi {path}"))?;

Kadang tidak ada error yang mendasarinya. Kamu sendiri yang memutuskan bahwa ada yang salah. anyhow punya tiga makro untuk itu:

  • bail!("...") langsung mengembalikan error saat itu juga. Ini singkatan dari return Err(anyhow!("...")).
  • ensure!(kondisi, "...") mengembalikan error kalau kondisinya bernilai false.
  • anyhow!("...") membuat nilai error tanpa mengembalikannya.

Ketiganya menerima pesan dengan gaya format!.

use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> {
ensure!(amount > 0, "jumlah harus lebih dari nol");
if amount > balance {
bail!("saldo tidak cukup: saldo {balance}, mencoba mengambil {amount}");
}
Ok(balance - amount)
}
fn find_user(id: u32) -> Result<&'static str> {
match id {
1 => Ok("Ferris"),
_ => Err(anyhow!("tidak ada pengguna dengan id {id}")),
}
}
fn main() {
println!("{:?}", withdraw(100, 30).ok());
for (balance, amount) in [(100, 0), (100, 250)] {
if let Err(e) = withdraw(balance, amount) {
println!("error: {e}");
}
}
println!("{}", find_user(1).unwrap());
println!("error: {}", find_user(7).unwrap_err());
}

Output:

Some(70)
error: jumlah harus lebih dari nol
error: saldo tidak cukup: saldo 100, mencoba mengambil 250
Ferris
error: tidak ada pengguna dengan id 7
▶ Coba sendiri

Tempat paling umum kamu melihat anyhow adalah di sini: main mengembalikan anyhow::Result<()>, dan kamu memakai ? di mana-mana. Kalau sebuah error sampai ke main, Rust mencetaknya dengan {:?} ke stderr dan keluar dengan kode 1:

use anyhow::{Context, Result};
fn main() -> Result<()> {
let text = "empat puluh dua";
let n: i32 = text
.parse()
.with_context(|| format!("tidak bisa membaca {text:?} sebagai angka"))?;
println!("{n}");
Ok(())
}
▶ Coba sendiri

Tidak ada yang dicetak ke stdout. Di stderr kamu akan melihat:

Error: tidak bisa membaca "empat puluh dua" sebagai angka
Caused by:
invalid digit found in string

Berikut bentuk umum sebuah proyek sungguhan. Modul inventory berperan sebagai library: ia mengembalikan InventoryError yang presisi, dibuat dengan thiserror. Kode aplikasinya memakai anyhow, menambahkan context, dan hanya melihat isi error di tempat yang benar-benar penting. downcast_ref bertanya pada sebuah anyhow::Error, “apakah sebenarnya kamu tipe ini?”, dan tetap bekerja meskipun sudah ditambahi context:

// ----- bagian "library": error yang presisi dengan thiserror -----
mod inventory {
use thiserror::Error;
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("item `{0}` tidak dikenal")]
UnknownItem(String),
#[error("hanya tersisa {available}, tidak bisa mengambil {wanted}")]
NotEnough { available: u32, wanted: u32 },
}
pub struct Store {
pub apples: u32,
}
impl Store {
pub fn take(&mut self, item: &str, wanted: u32) -> Result<(), InventoryError> {
if item != "apel" {
return Err(InventoryError::UnknownItem(item.to_string()));
}
if wanted > self.apples {
return Err(InventoryError::NotEnough { available: self.apples, wanted });
}
self.apples -= wanted;
Ok(())
}
}
}
// ----- bagian "aplikasi": anyhow + context -----
use anyhow::{Context, Result};
use inventory::{InventoryError, Store};
fn process_order(store: &mut Store, id: u32, item: &str, amount: u32) -> Result<()> {
store
.take(item, amount)
.with_context(|| format!("pesanan #{id} gagal"))?;
println!("pesanan #{id} selesai");
Ok(())
}
fn main() {
let mut store = Store { apples: 5 };
let orders = [(1, "apel", 2), (2, "plum", 1), (3, "apel", 9)];
for (id, item, amount) in orders {
if let Err(e) = process_order(&mut store, id, item, amount) {
println!("error: {e:#}");
// Kita masih bisa melihat tipe thiserror aslinya di dalam:
if let Some(InventoryError::NotEnough { available, .. }) =
e.downcast_ref::<InventoryError>()
{
println!(" petunjuk: coba pesan {available} atau kurang");
}
}
}
println!("sisa apel: {}", store.apples);
}

Output:

pesanan #1 selesai
error: pesanan #2 gagal: item `plum` tidak dikenal
error: pesanan #3 gagal: hanya tersisa 3, tidak bisa mengambil 9
petunjuk: coba pesan 3 atau kurang
sisa apel: 3
▶ Coba sendiri

Lupa mengimpor Context. .context() adalah method dari trait anyhow::Context, dan method milik trait hanya bisa dipakai kalau trait-nya ada di dalam scope:

use anyhow::Result;
fn parse(text: &str) -> Result<i32> {
let n: i32 = text.parse().context("not a number")?;
Ok(n)
}
error[E0599]: no method named `context` found for enum `Result<T, E>` in the current scope
--> src/main.rs:4:31
|
4 | let n: i32 = text.parse().context("not a number")?;
| ^^^^^^^
|
= help: items from traits can only be used if the trait is in scope
help: trait `Context` which provides `context` is implemented but not in scope; perhaps you want to import it
|
1 + use anyhow::Context;
|

Compiler bilang method context tidak ditemukan, karena trait Context belum ada di scope, dan ia bahkan menyarankan baris use yang kurang. Perbaikannya: use anyhow::{Context, Result};.

Mengulang pesan source di dalam pesanmu sendiri. Dengan #[from], error di dalamnya sudah menjadi source. Kalau kamu juga menaruh {0} di pesannya, laporan lengkap akan mencetaknya dua kali:

#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("port tidak valid: {0}")] // memuat teks dari source...
BadPort(#[from] ParseIntError), // ...dan ini juga source-nya
}

Kalau dicetak dengan {:#} lewat anyhow, hasilnya port tidak valid: invalid digit found in string: invalid digit found in string. Perbaikannya: tulis #[error("port tidak valid")] dan biarkan rantai error yang menampilkan penyebabnya, atau tetap pakai {0} tapi jangan tandai field itu sebagai source. Pilih salah satu.

Mencetak dengan {} lalu kehilangan penyebabnya. println!("{e}") pada anyhow::Error hanya menampilkan pesan paling luar, misalnya “gagal memuat pengaturan”. Saat mencetak error untuk dibaca manusia, pakai {:#} (satu baris) atau {:?} (laporan lengkap).

Memakai anyhow di API publik sebuah library. Kalau library-mu mengembalikan anyhow::Result, pemanggilnya tidak bisa men-match error-mu. Mereka harus menebak tipenya dengan downcast_ref. Beri fungsi publik enum dari thiserror, dan simpan anyhow untuk aplikasi, test, dan contoh.

  • thiserror untuk mendefinisikan error: #[derive(Error)] ditambah pesan #[error("...")] di setiap varian menggantikan impl Display dan impl Error yang kamu tulis manual.
  • #[from] membuat ? mengubah error di dalamnya menjadi varianmu dan mencatatnya sebagai source. #[source] mencatat source tanpa konversi itu.
  • anyhow untuk meneruskan error ke atas: anyhow::Result<T> menerima error apa pun lewat ?.
  • .context() dan .with_context() menambahkan lapisan yang mudah dibaca manusia ke rantai error. Cetak rantainya dengan {:#} (satu baris) atau {:?} (laporan).
  • bail! langsung mengembalikan error, ensure! mengembalikan error kalau sebuah kondisi bernilai false, dan anyhow! membuat error.
  • Susunan yang umum: thiserror di kode library, anyhow di main.

Kuis singkat

1. Kamu sedang menulis library, dan pemanggilnya perlu bereaksi berbeda untuk timeout dan input yang salah. Crate mana yang paling cocok?

2. Apa yang dilakukan #[from] pada field sebuah varian?

3. Format mana yang mencetak anyhow::Error beserta seluruh rantainya dalam satu baris?

4. Kapan sebaiknya kamu memakai .with_context(|| ...) alih-alih .context(...)?

5. Apa yang dilakukan ensure!(amount > 0, "jumlah harus positif") saat amount bernilai 0?

Skor: 0 / 5