Langsung ke konten
KamusNgoding
Mahir Go 4 menit baca

Panduan Lengkap Manajemen Error di Go

#go #golang #error handling #best practice #advanced

Panduan Lengkap Manajemen Error di Go

Pendahuluan

Go memiliki filosofi yang unik dalam menangani error: error adalah nilai biasa, bukan exception. Tidak ada try/catch, tidak ada stack unwinding otomatis. Sebagai gantinya, Go mendorong developer untuk menangani error secara eksplisit di setiap tahap.

Pendekatan ini terkesan verbose di awal, tapi justru inilah kekuatan Go — kamu tahu persis kapan dan di mana sesuatu bisa gagal. Bayangkan kamu membangun sistem pembayaran seperti yang ada di GoPay: setiap transaksi yang gagal harus ditangani dengan jelas, bukan “hilang” karena diabaikan.

Artikel ini membahas manajemen error tingkat lanjut: wrapping, inspeksi error chain, custom error types, panic/recover, dan penanganan error di lingkungan konkuren.


Error Wrapping: Memberi Konteks pada Error Anda

Sejak Go 1.13, kamu bisa membungkus (wrap) error untuk menambahkan konteks tanpa kehilangan error aslinya.

Menggunakan fmt.Errorf dengan %w

package main

import (
	"errors"
	"fmt"
)

// errDBTimeout adalah sentinel error yang bisa dicek dengan errors.Is setelah dibungkus.
var errDBTimeout = errors.New("koneksi database timeout")

func queryDatabase(userID int) error {
	_ = userID
	return fmt.Errorf("query database: %w", errDBTimeout)
}

func getUserData(userID int) error {
	if err := queryDatabase(userID); err != nil {
		return fmt.Errorf("getUserData(userID=%d): %w", userID, err)
	}
	return nil
}

func handleRequest(userID int) error {
	if err := getUserData(userID); err != nil {
		return fmt.Errorf("handleRequest(userID=%d): %w", userID, err)
	}
	return nil
}

func main() {
	if err := handleRequest(42); err != nil {
		fmt.Println("operasi gagal:", err)

		// errors.Is tetap bisa menemukan error asal walaupun sudah dibungkus berkali-kali.
		if errors.Is(err, errDBTimeout) {
			fmt.Println("penyebab utama: timeout database")
		}
	}
}

/*
Output yang diharapkan:
> operasi gagal: handleRequest(userID=42): getUserData(userID=42): query database: koneksi database timeout
> penyebab utama: timeout database
*/

Perhatikan bagaimana setiap lapisan menambahkan konteks. Ketika error ini muncul di log, kamu langsung tahu jalur eksekusinya — dari handleRequestgetUserDataqueryDatabase.

Mengapa %w dan Bukan %v?

%v mengkonversi error menjadi string biasa — informasi tipe hilang. %w menjaga referensi ke error asli, sehingga bisa diinspeksi nanti dengan errors.Is() dan errors.As().


Inspeksi Error Chain: errors.Is() dan errors.As()

errors.Is() — Membandingkan Identitas Error

errors.Is() menelusuri seluruh chain untuk mencari apakah error target ada di dalamnya.

package main

import (
	"errors"
	"fmt"
)

// ErrNotFound dan ErrPermission adalah sentinel error
// yang bisa dicek ulang dengan errors.Is setelah dibungkus.
var (
	ErrNotFound   = errors.New("data tidak ditemukan")
	ErrPermission = errors.New("akses ditolak")
)

func fetchItem(id int) error {
	switch {
	case id == 0:
		return ErrNotFound
	case id < 0:
		return ErrPermission
	default:
		return nil
	}
}

func processItem(id int) error {
	if err := fetchItem(id); err != nil {
		return fmt.Errorf("process item %d: %w", id, err)
	}
	return nil
}

func main() {
	ids := []int{0, -1}

	for _, id := range ids {
		err := processItem(id)
		if err == nil {
			fmt.Println("Item berhasil diproses")
			continue
		}

		switch {
		case errors.Is(err, ErrNotFound):
			fmt.Println("Item tidak ada, tampilkan halaman 404")
		case errors.Is(err, ErrPermission):
			fmt.Println("User tidak punya akses")
		default:
			fmt.Println("Terjadi error:", err)
		}
	}
}

/*
Output yang diharapkan:
> Item tidak ada, tampilkan halaman 404
> User tidak punya akses
*/

Meskipun err sudah di-wrap oleh processItem, errors.Is() tetap menemukan ErrNotFound di dalam chain.

errors.As() — Mengekstrak Tipe Error Tertentu

package main

import (
	"errors"
	"fmt"
	"strings"
)

var ErrInvalidInput = errors.New("input tidak valid")

type ValidationError struct {
	Field   string
	Value   string
	Message string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validasi gagal pada field %q: %s", e.Field, e.Message)
}

// Unwrap membuat ValidationError tetap bisa dikenali sebagai ErrInvalidInput.
func (e *ValidationError) Unwrap() error {
	return ErrInvalidInput
}

func validateEmail(email string) error {
	email = strings.TrimSpace(email)

	if email == "" {
		return &ValidationError{
			Field:   "email",
			Value:   email,
			Message: "tidak boleh kosong",
		}
	}

	if !strings.Contains(email, "@") {
		return &ValidationError{
			Field:   "email",
			Value:   email,
			Message: "harus mengandung karakter '@'",
		}
	}

	return nil
}

func registerUser(email string) error {
	if err := validateEmail(email); err != nil {
		return fmt.Errorf("registerUser: %w", err)
	}
	return nil
}

func main() {
	err := registerUser("invalid-email")

	if err == nil {
		fmt.Println("registrasi berhasil")
		return
	}

	// errors.Is cocok untuk kategori error tingkat tinggi.
	if errors.Is(err, ErrInvalidInput) {
		fmt.Println("kategori error: input tidak valid")
	}

	// errors.As mengambil detail error konkret untuk respons yang lebih spesifik.
	var valErr *ValidationError
	if errors.As(err, &valErr) {
		fmt.Printf("field bermasalah: %s\n", valErr.Field)
		fmt.Printf("pesan: %s\n", valErr.Message)
		return
	}

	fmt.Println("error:", err)
}

/*
Output yang diharapkan:
> kategori error: input tidak valid
> field bermasalah: email
> pesan: harus mengandung karakter '@'
*/

errors.As() sangat berguna untuk REST API — kamu bisa membedakan error validasi dari error database, lalu mengembalikan HTTP status code yang tepat (400 vs 500).


Custom Error Types: Membawa Data Tambahan

Terkadang string pesan saja tidak cukup. Custom error type memungkinkan kamu menyertakan data terstruktur yang bisa diakses oleh pemanggil.

package main

import (
	"errors"
	"fmt"
	"time"
)

// AppError menyimpan konteks error tingkat aplikasi dan membungkus error asli.
type AppError struct {
	Code      int
	Message   string
	Timestamp time.Time
	Err       error
}

func (e *AppError) Error() string {
	return fmt.Sprintf("[%d] %s (at %s)", e.Code, e.Message, e.Timestamp.Format(time.RFC3339))
}

// Unwrap memungkinkan errors.As / errors.Is menelusuri error yang dibungkus.
func (e *AppError) Unwrap() error {
	return e.Err
}

func fetchPayment(txID string) error {
	if txID != "TXN-999" {
		baseErr := errors.New("record tidak ditemukan di tabel payments")
		return &AppError{
			Code:      404,
			Message:   "transaksi tidak ditemukan",
			Timestamp: time.Date(2026, 4, 5, 10, 30, 0, 0, time.UTC),
			Err:       baseErr,
		}
	}
	return nil
}

func main() {
	err := fetchPayment("TXN-001")
	if err == nil {
		fmt.Println("pembayaran ditemukan")
		return
	}

	fmt.Println(err)

	var appErr *AppError
	if errors.As(err, &appErr) {
		fmt.Printf("HTTP status: %d\n", appErr.Code)
		fmt.Printf("penyebab asli: %v\n", appErr.Err)
	}
}

/*
Output yang diharapkan:
> [404] transaksi tidak ditemukan (at 2026-04-05T10:30:00Z)
> HTTP status: 404
> penyebab asli: record tidak ditemukan di tabel payments
*/

Method Unwrap() adalah kunci — tanpanya, errors.Is() dan errors.As() tidak bisa menelusuri chain milik custom error kamu.


Panic dan Recover: Kapan Digunakan?

panic bukan pengganti error handling normal. Gunakan panic hanya untuk kondisi yang seharusnya tidak pernah terjadi — seperti bug programmer, bukan kondisi runtime yang bisa diprediksi.

package main

import (
	"fmt"
)

// safeDivide membungkus panic menjadi error agar boundary fungsi tetap aman.
func safeDivide(a, b int) (result int, err error) {
	// recover hanya efektif jika dipanggil di dalam deferred function.
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("safeDivide panic: %v", r)
		}
	}()

	if b == 0 {
		panic("pembagian dengan nol")
	}

	return a / b, nil
}

func main() {
	// Kasus panic yang dipulihkan menjadi error.
	result, err := safeDivide(10, 0)
	if err != nil {
		fmt.Println("error:", err)
	} else {
		fmt.Println("hasil:", result)
	}

	// Kasus normal.
	result, err = safeDivide(10, 2)
	if err != nil {
		fmt.Println("error:", err)
	} else {
		fmt.Println("hasil:", result)
	}
}

/*
Output yang diharapkan:
> error: safeDivide panic: pembagian dengan nol
> hasil: 5
*/

recover() hanya bekerja di dalam defer. Pattern ini sering digunakan di library Go untuk mencegah goroutine internal mem-crash seluruh program.


Error Handling di Goroutine

Error tidak bisa diteruskan langsung antar goroutine melalui return. Gunakan channel untuk mengumpulkan hasilnya secara aman.

package main

import (
	"fmt"
	"sync"
)

type Result struct {
	ID    int
	Value int
	Err   error
}

func processTask(id int, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()

	if id%3 == 0 {
		results <- Result{ID: id, Err: fmt.Errorf("id %d tidak valid (kelipatan 3)", id)}
		return
	}

	results <- Result{ID: id, Value: id * 2}
}

func main() {
	var wg sync.WaitGroup
	results := make(chan Result, 10)
	ids := []int{1, 3, 5, 6, 9}

	for _, id := range ids {
		wg.Add(1)
		go processTask(id, results, &wg)
	}

	// Tutup channel setelah semua goroutine selesai.
	go func() {
		wg.Wait()
		close(results)
	}()

	for result := range results {
		if result.Err != nil {
			fmt.Printf("Error untuk ID %d: %v\n", result.ID, result.Err)
		} else {
			fmt.Printf("Hasil untuk ID %d: %d\n", result.ID, result.Value)
		}
	}
}

/*
Output yang diharapkan (urutan bisa berbeda karena goroutine berjalan paralel):
> Hasil untuk ID 1: 2
> Error untuk ID 3: id 3 tidak valid (kelipatan 3)
> Hasil untuk ID 5: 10
> Error untuk ID 6: id 6 tidak valid (kelipatan 3)
> Error untuk ID 9: id 9 tidak valid (kelipatan 3)
*/

Untuk kasus yang lebih kompleks, paket golang.org/x/sync/errgroup menyederhanakan pola ini — ia menghentikan semua goroutine saat salah satu gagal dan mengembalikan error pertama yang terjadi.


Pertanyaan yang Sering Diajukan

Apa perbedaan errors.New() dan fmt.Errorf() di Go?

errors.New() membuat error sederhana dari string tanpa kemampuan wrapping. fmt.Errorf() dengan verb %w membuat error yang membungkus error lain sehingga chain-nya bisa ditelusuri dengan errors.Is() dan errors.As(). Gunakan errors.New() untuk sentinel error global, dan fmt.Errorf("%w", ...) untuk menambahkan konteks saat meneruskan error antar lapisan.

Kapan sebaiknya menggunakan panic daripada mengembalikan error?

panic diperuntukkan untuk kondisi yang mencerminkan bug programmer — misalnya, indeks array di luar batas akibat logika yang salah, atau dependency yang tidak diinisialisasi. Untuk semua kondisi error yang bisa terjadi saat runtime (kegagalan jaringan, input tidak valid, file tidak ada), selalu gunakan return error. Aturan sederhananya: jika kondisi itu bisa terjadi di production pada kode yang benar, gunakan error; jika tidak seharusnya terjadi sama sekali, gunakan panic.

Bagaimana cara menangani multiple error dari goroutine secara aman?

Gunakan channel bertipe struct yang membawa error dan data hasil, lalu kumpulkan hasilnya setelah semua goroutine selesai. Hindari menulis ke shared variable tanpa mutex karena bisa menyebabkan race condition. Untuk kasus yang lebih kompleks, paket golang.org/x/sync/errgroup menyederhanakan pola ini secara signifikan.

Apa itu sentinel error dan apa risikonya?

Sentinel error adalah variabel error global seperti var ErrNotFound = errors.New("not found"). Risikonya adalah coupling — package lain yang mengimport sentinel error kamu bergantung langsung pada package tersebut. Untuk library publik, pertimbangkan menggunakan interface atau custom error type agar konsumen bisa mengecek perilaku error tanpa bergantung pada nilai spesifik.

Bagaimana cara men-log error chain secara lengkap?

Cukup panggil err.Error() untuk mendapatkan seluruh chain sebagai string, karena setiap wrap menyertakan pesan layer sebelumnya. Untuk structured logging, gunakan library seperti slog (standar library Go 1.21+) atau zap yang mendukung field error. Hindari memanggil errors.Unwrap() berulang secara manual — gunakan errors.Is() dan errors.As() sebagai gantinya.


Kesimpulan

Manajemen error yang baik di Go bukan soal menulis if err != nil sebanyak mungkin, melainkan soal memberi konteks yang tepat di setiap lapisan dan mengekspos informasi yang dibutuhkan pihak pemanggil. Tiga prinsip utama:

  1. Wrap dengan %w — tambahkan konteks, jaga chain tetap utuh
  2. Gunakan errors.Is() / errors.As() — inspeksi error berdasarkan identitas dan tipe, bukan string
  3. Buat custom error type untuk membawa data tambahan yang relevan bagi pemanggil

Dengan menguasai pola-pola ini, kamu bisa membangun sistem yang mudah di-debug, memberikan pesan error yang bermakna ke pengguna, dan menangani kegagalan dengan graceful — baik di aplikasi CLI sederhana maupun microservice berskala besar.

Artikel Terkait