Error yang lahir di dalam kode kita sering membawa informasi berlebih: kegagalan parsing menyimpan posisi karakter yang salah, kegagalan query menyimpan teks SQL utuh, dan kegagalan rate limit menyimpan nama gateway serta isi token bucket. Informasi tersebut sangat berharga bagi developer yang memperbaiki sistem, tetapi sebagian besar tidak boleh sampai ke pengguna akhir — teks SQL membocorkan struktur basis data, nama gateway memberi peta bagi penyerang, dan pesan teknis yang berubah tiap rilis membuat dokumentasi publik kita tidak stabil. Desain error API adalah keputusan tentang error surface: bagian mana dari kegagalan yang kita omongkan kepada dunia luar. Pada chapter ini kita membangun tipe ApiError yang memisahkan dua kanal — pesan publik yang aman lewat Display, dan detail internal yang hanya muncul di log lewat Debug — sambil tetap memberi pemanggil varian error yang bisa dijadikan dasar keputusan.
Dua Audiens Pesan Error #
Pesan error yang baik untuk pengguna akhir berbeda dari pesan error yang baik untuk developer. Pengguna akhir membutuhkan kalimat yang bisa ditindaklanjuti: “input tidak valid, periksa kembali data Anda” atau “permintaan terlalu sering, coba lagi dalam 30 detik”. Developer membutuhkan jejak lengkap: teks SQL yang gagal, angka token bucket yang penuh, dan asal gateway tempat kegagalan terjadi. Kebiasaan buruk yang sering kita temui adalah mencampur keduanya menjadi satu string — format!("gagal mengeksekusi {}: {}", query, cause) — lalu mengirim string tersebut ke mana-mana.
Pencampuran tersebut merugikan dua arah sekaligus. Pengguna akhir menerima kebocoran: struktur tabel, nama host internal, dan pesan library pihak ketiga yang tidak dimaksudkan untuk publik. Developer kehilangan kontrak: karena pesan yang tampil ke pengguna adalah string bebas, setiap perbaikan internal bisa mengubah teks publik tanpa disadari, dan kode pemanggil yang mencocokkan teks pesan akan rusak diam-diam. Solusinya bukan menyembunyikan semua informasi, melainkan memisahkan kanalnya dan memakai tipe untuk menjaga pemisahan tersebut.
ApiError dengan Varian dan Detail Privat #
Rancangannya seperti berikut: enum ErrorKind menyimpan jenis kegagalan beserta data yang aman untuk publik, struct ApiError menyimpan kind plus satu field detail yang sepenuhnya privat. Trait Display mencetak hanya pesan aman per varian; trait Debug sengaja mencetak kind dan detail sebagai kanal developer. Program lengkapnya:
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
InvalidInput,
NotFound,
RateLimited { retry_after_secs: u64 },
}
pub struct ApiError {
kind: ErrorKind,
// detail internal: hanya boleh muncul di log, tidak di pesan pengguna
detail: String,
}
impl ApiError {
// konstruktor privat: detail internal tidak bisa diisi dari luar crate
fn new(kind: ErrorKind, detail: String) -> Self {
Self { kind, detail }
}
// getter tanpa awalan get_, sesuai konvensi komunitas Rust
pub fn kind(&self) -> ErrorKind {
self.kind
}
}
// Display: pesan aman untuk pengguna akhir, tanpa detail internal
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self.kind {
ErrorKind::InvalidInput => {
write!(f, "input tidak valid, periksa kembali data Anda")
}
ErrorKind::NotFound => write!(f, "data yang diminta tidak ditemukan"),
ErrorKind::RateLimited { retry_after_secs } => {
write!(f, "permintaan terlalu sering, coba lagi dalam {} detik", retry_after_secs)
}
}
}
}
// Debug: kanal khusus developer, sengaja memuat detail internal untuk log
impl fmt::Debug for ApiError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_struct("ApiError")
.field("kind", &self.kind)
.field("detail", &self.detail)
.finish()
}
}
// Tanda tipe error yang sah; tanpa ini ApiError tidak bisa masuk Box<dyn Error>
impl std::error::Error for ApiError {}
fn parse_port(input: &str) -> Result<u16, ApiError> {
input.parse::<u16>().map_err(|raw| {
// ParseIntError membawa detail internal; simpan untuk log, jangan bocorkan
ApiError::new(ErrorKind::InvalidInput, raw.to_string())
})
}
fn find_user(id: u64) -> Result<String, ApiError> {
if id == 7 {
Ok(String::from("Budi Santoso"))
} else {
Err(ApiError::new(
ErrorKind::NotFound,
format!("SELECT nama FROM pengguna WHERE id={} mengembalikan 0 baris", id),
))
}
}
fn main() {
println!("Port tervalidasi: {}", parse_port("8080").unwrap());
for input in ["delapan", "99999"] {
match parse_port(input) {
Ok(port) => println!("Input '{}' sah: {}", input, port),
Err(error) => println!("Pesan publik untuk '{}': {}", input, error),
}
}
match find_user(42) {
Ok(nama) => println!("Pengguna ditemukan: {}", nama),
Err(error) => {
println!("Pesan publik: {}", error);
println!("Baris log internal: {:?}", error);
}
}
let throttled = ApiError::new(
ErrorKind::RateLimited { retry_after_secs: 30 },
String::from("token bucket 92/100 terpakai, sumber: api-gateway-03"),
);
println!("Pesan publik: {}", throttled);
match throttled.kind() {
ErrorKind::RateLimited { retry_after_secs } => {
println!("Keputusan pemanggil: klien menunggu {} detik", retry_after_secs)
}
_ => println!("Keputusan pemanggil: tanpa jeda khusus"),
}
println!("Baris log internal: {:?}", throttled);
}Output yang Dihasilkan:
Port tervalidasi: 8080
Pesan publik untuk 'delapan': input tidak valid, periksa kembali data Anda
Pesan publik untuk '99999': input tidak valid, periksa kembali data Anda
Pesan publik: data yang diminta tidak ditemukan
Baris log internal: ApiError { kind: NotFound, detail: "SELECT nama FROM pengguna WHERE id=42 mengembalikan 0 baris" }
Pesan publik: permintaan terlalu sering, coba lagi dalam 30 detik
Keputusan pemanggil: klien menunggu 30 detik
Baris log internal: ApiError { kind: RateLimited { retry_after_secs: 30 }, detail: "token bucket 92/100 terpakai, sumber: api-gateway-03" }Mari kita bedah kode di atas:
- Baris 3-8 (
pub enum ErrorKind { ... }): enum ini adalah kosakata kegagalan yang kita terbitkan. VarianRateLimitedmembawaretry_after_secs— data yang aman sekaligus berguna, karena pengguna berhak tahu berapa lama harus menunggu. Trait turunanClone,Copy, danPartialEqmembuat varian bisa disalin dan dibandingkan oleh pemanggil. - Baris 10-14 (
pub struct ApiError { kind, detail }): dua lapis penyimpanan. Fieldkindbisa dibaca lewat method, sedangkan fielddetailsepenuhnya privat —Displaytidak akan pernah menyentuhnya, dan di crate nyata hanya kode di dalam modulApiErroryang bisa mengisinya. - Baris 18 (
fn new(kind: ErrorKind, detail: String) -> Self): konstruktor sengaja tidak diberipub. Pemanggil luar tidak bisa merakitApiErrordengan detail sembarangan; detail hanya lahir dari tempat kegagalan sesungguhnya terjadi, sepertiparse_portdanfind_user. - Baris 29-41 (
impl fmt::Display for ApiError):match self.kindmencetak pesan per varian. Seluruh pesan ditulis tangan, stabil, dan bebas detail — pengguna membaca “data yang diminta tidak ditemukan”, bukan teks SQL di belakangnya. - Baris 44-51 (
impl fmt::Debug for ApiError):Debugjustru sengaja memuatdetaillewatf.debug_struct. Pemisahan peran kedua trait inilah inti error surface:format!("{}", error)untuk pengguna,format!("{:?}", error)untuk baris log. - Baris 54 (
impl std::error::Error for ApiError {}): satu baris penanda bahwaApiErroradalah tipe error yang sah, sehingga bisa masukBox<dyn Error>pada aplikasi yang memakai pola tersebut — materi yang sudah kita kenal dari section Penanganan Error. - Baris 56-61 (
fn parse_port ... map_err):ParseIntErrormentah diterjemahkan menjadiErrorKind::InvalidInput, sementara pesan aslinya disimpan sebagaidetail. Pemanggil melihat varian yang stabil; developer tetap bisa membaca “number too large to fit in target type” di log. - Baris 63-72 (
fn find_user): detail kegagalan kali ini berisi teks query. Informasi ini memang berguna untuk debugging internal, tetapi berbahaya bila bocor — dan rancangan kita memastikan informasi tersebut hanya keluar lewat kanalDebug. - Baris 84-90 (cabang
Errpadafind_user): duaprintln!menampilkan dua kanal berdampingan. Baris “Pesan publik” aman ditampilkan ke pengguna; baris “Baris log internal” hanya milik developer. - Baris 92-103 (
let throttled = ...): skenario rate limit menunjukkan sisi pemrograman dari error surface. Pemanggil memanggilkind()lalu mencocokkan varianRateLimiteduntuk mengatur jeda klien — keputusan berbasis varian, bukan berbasis teks pesan.
Mengapa Detail Disembunyikan #
Tiga alasan menjadikan pemisahan ini standar. Pertama, keamanan: teks SQL membuka struktur tabel, nama gateway membuka topologi jaringan internal, dan pesan library pihak ketiga membuka versi komponen yang kita pakai — semuanya bahan bagi penyerang yang sedang memetakan sistem. Kedua, stabilitas kontrak: pesan Display adalah bagian dari API kita, jadi pesan tersebut sebaiknya hanya berubah bila kita sengaja mengubahnya; detail internal boleh berbeda tiap rilis karena detail tidak pernah menjadi kontrak. Ketiga, kejelasan pemanggil: varian ErrorKind bisa dicocokkan dengan match, sedangkan string error mengajak pemanggil menulis pencocokan teks yang rapuh — compiler tidak akan mengecek teks pesan, tetapi compiler akan mengecek varian yang lupa ditangani.
Ringkasan #
Desain error pada API memisahkan dua kanal lewat dua trait: Display mencetak pesan publik yang stabil dan informatif per varian ErrorKind, sedangkan Debug menjadi kanal developer yang memuat detail internal untuk log. Field detail dibuat privat beserta konstruktor non-publik, sehingga kegagalan mentah seperti ParseIntError dan teks SQL diterjemahkan di tempat kejadian tanpa bisa dirakit dari luar. Pemanggil mengambil keputusan lewat kind() dan match atas varian, bukan lewat teks pesan, dan impl std::error::Error menjaga kompatibilitas dengan Box<dyn Error>. Error kini aman diekspos; tinggal satu perlengkapan terakhir sebelum API kita terasa utuh — kepatuhan pada konvensi penamaan dan dokumentasi komunitas yang kita rangkum pada chapter Rust API Guidelines.