Eksepsi #
Exception handling adalah perbedaan antara program yang crash dengan pesan yang tidak bisa dimengerti dan program yang gagal dengan anggun — mencatat apa yang salah, membersihkan resource yang dipakai, dan memberi tahu pengguna dengan pesan yang bermakna. Ruby menggunakan mekanisme begin/rescue/else/ensure yang elegan dan ekspresif. Tapi ada nuansa penting yang sering diabaikan: hierarki exception Ruby, kapan harus rescue dan kapan harus membiarkan exception menyebar, cara retry yang benar, dan mengapa rescue Exception adalah anti-pattern yang berbahaya. Artikel ini membahas semua ini dari fondasi hingga pola yang digunakan di aplikasi produksi.
Hierarki Exception di Ruby #
Sebelum menulis rescue, penting untuk memahami hierarki kelas exception Ruby — karena rescue bekerja berdasarkan hubungan inheritance, bukan pencocokan string.
Exception
├── NoMemoryError
├── ScriptError
│ ├── LoadError
│ ├── NotImplementedError
│ └── SyntaxError
├── SignalException
│ ├── Interrupt ← Ctrl+C
│ └── Signal
├── SystemExit ← exit()
└── StandardError ← rescue tanpa tipe menangkap ini
├── ArgumentError
├── EncodingError
├── FiberError
├── IOError
│ └── EOFError
├── IndexError
│ ├── KeyError
│ └── StopIteration
├── Math::DomainError
├── NameError
│ └── NoMethodError
├── RangeError
│ └── FloatDomainError
├── RegexpError
├── RuntimeError ← raise "pesan" menghasilkan ini
├── SystemCallError ← error dari OS
├── ThreadError
├── TypeError
├── ZeroDivisionError
└── (exception kustom kamu)
Poin krusial: rescue tanpa tipe eksplisit hanya menangkap StandardError dan turunannya — bukan Exception. Ini adalah perilaku yang diinginkan karena Interrupt, SystemExit, dan NoMemoryError sebaiknya tidak ditangkap secara sembarangan.
# rescue => e ekuivalen dengan rescue StandardError => e
begin
# kode berisiko
rescue => e
puts e.message # hanya menangkap StandardError dan turunannya
end
# ANTI-PATTERN: rescue Exception menangkap SEGALANYA termasuk Ctrl+C!
begin
loop { sleep 1 }
rescue Exception => e # ← ini menangkap Interrupt (Ctrl+C) — program tidak bisa dihentikan!
puts "Ditangkap: #{e.class}"
end
Struktur begin/rescue/else/ensure #
Struktur lengkap exception handling di Ruby punya empat bagian, masing-masing dengan peran yang berbeda:
begin
# Kode yang mungkin melempar exception
hasil = operasi_berisiko()
rescue ArgumentError => e
# Tangani ArgumentError secara spesifik
puts "Argumen tidak valid: #{e.message}"
rescue TypeError, ValueError => e
# Tangani beberapa tipe sekaligus dalam satu rescue
puts "Tipe atau nilai salah: #{e.message}"
rescue IOError => e
# Tangani error I/O
puts "Error file/jaringan: #{e.message}"
rescue => e
# Fallback untuk StandardError lain yang tidak ditangkap di atas
puts "Error tidak terduga: #{e.class} — #{e.message}"
else
# Dijalankan HANYA jika tidak ada exception yang terjadi
puts "Berhasil! Hasil: #{hasil}"
ensure
# Dijalankan SELALU — baik ada exception maupun tidak
# Tempat yang tepat untuk menutup file, koneksi, dsb.
bersihkan_resource()
end
flowchart TD
A[begin block] --> B{Exception terjadi?}
B -- Tidak --> C[else block\ndijalankan]
B -- Ya --> D{Cocok dengan\nrescue mana?}
D -- rescue 1 --> E[Tangani error 1]
D -- rescue 2 --> F[Tangani error 2]
D -- rescue fallback --> G[Tangani error umum]
D -- Tidak ada yang cocok --> H[Exception menyebar\nke caller]
C --> I[ensure block\nselalu dijalankan]
E --> I
F --> I
G --> I
H --> Ielse — Sering Dilupakan #
Blok else adalah yang paling sering tidak diketahui developer baru. Ia berjalan hanya ketika tidak ada exception — memisahkan “kode yang berhasil” dari “kode yang mungkin gagal”:
def baca_konfigurasi(path)
begin
konten = File.read(path)
rescue Errno::ENOENT => e
puts "File tidak ditemukan: #{path}"
return {}
rescue Errno::EACCES => e
puts "Tidak ada izin membaca: #{path}"
return {}
else
# Hanya sampai sini jika File.read berhasil
JSON.parse(konten)
ensure
puts "Selesai mencoba membaca #{path}"
end
end
ensure — Resource Cleanup #
ensure adalah tempat yang tepat untuk menutup file, koneksi database, atau resource apapun yang harus dibersihkan terlepas dari apa yang terjadi:
def proses_file(path)
file = nil
begin
file = File.open(path, "r")
konten = file.read
proses(konten)
rescue IOError => e
log.error("Gagal membaca file: #{e.message}")
raise # lempar ulang setelah logging
ensure
file&.close # tutup file SELALU, bahkan jika raise dipanggil
end
end
# Cara yang lebih idiomatik: gunakan blok File.open
# yang otomatis menutup file di akhir blok
def proses_file(path)
File.open(path, "r") do |file|
proses(file.read)
end
rescue IOError => e
log.error("Gagal membaca file: #{e.message}")
raise
end
raise — Melempar Exception #
raise digunakan untuk melempar exception secara eksplisit. Ada beberapa bentuk:
# Bentuk 1: raise dengan String — menghasilkan RuntimeError
raise "Terjadi kesalahan!"
# Bentuk 2: raise dengan kelas exception
raise ArgumentError
raise ArgumentError, "Umur harus positif"
# Bentuk 3: raise dengan objek exception yang sudah dibuat
error = ArgumentError.new("Umur harus positif")
raise error
# Bentuk 4: raise tanpa argumen di dalam rescue — lempar ulang exception yang sedang ditangani
begin
operasi()
rescue => e
log.error(e.message)
raise # lempar ulang exception yang sama ke caller
end
raise dalam Validasi #
Pola paling umum menggunakan raise adalah validasi input di awal method:
def buat_pengguna(nama:, email:, umur:)
raise ArgumentError, "Nama tidak boleh kosong" if nama.to_s.strip.empty?
raise ArgumentError, "Format email tidak valid" unless email.match?(/\A\S+@\S+\.\S+\z/)
raise ArgumentError, "Umur harus antara 0-150" unless (0..150).include?(umur)
Pengguna.new(nama: nama, email: email, umur: umur)
end
begin
buat_pengguna(nama: "", email: "bukan-email", umur: -5)
rescue ArgumentError => e
puts "Validasi gagal: #{e.message}"
end
# => Validasi gagal: Nama tidak boleh kosong (berhenti di error pertama)
rescue di Dalam Method — Tanpa begin/end #
Ruby mengizinkan rescue langsung di dalam method tanpa perlu begin...end eksplisit. Seluruh body method secara implisit menjadi blok begin:
# ANTI-PATTERN: begin/end yang tidak perlu di dalam method
def bagi(a, b)
begin
a / b
rescue ZeroDivisionError
0
end
end
# BENAR: rescue langsung di method — lebih bersih
def bagi(a, b)
a / b
rescue ZeroDivisionError
0
end
# ensure juga bekerja di level method
def buka_dan_proses(path)
file = File.open(path)
proses(file.read)
rescue IOError => e
log.error(e)
nil
ensure
file&.close
end
retry — Coba Ulang Setelah Gagal #
retry mengulang blok begin dari awal — sangat berguna untuk operasi yang bisa gagal sementara seperti request jaringan atau koneksi database:
def ambil_data_dari_api(url, maks_percobaan: 3)
percobaan = 0
begin
percobaan += 1
puts "Percobaan ke-#{percobaan}..."
response = HTTP.get(url, timeout: 5)
response.body
rescue Net::TimeoutError, Errno::ECONNREFUSED => e
puts "Gagal: #{e.message}"
if percobaan < maks_percobaan
jeda = 2 ** percobaan # exponential backoff: 2s, 4s, 8s
puts "Menunggu #{jeda} detik sebelum coba ulang..."
sleep jeda
retry
else
raise "Gagal setelah #{maks_percobaan} percobaan: #{e.message}"
end
end
end
Selalu batasi jumlahretrydengan counter —retrytanpa batas bisa menyebabkan infinite loop. Pola exponential backoff (jeda yang semakin lama setiap percobaan) adalah standar industri untuk retry request jaringan karena tidak membebani server yang sudah kewalahan.
# Abstraksi retry yang bisa dipakai ulang
def dengan_retry(maks: 3, jeda: 1, exceptions: [StandardError])
percobaan = 0
begin
percobaan += 1
yield
rescue *exceptions => e
raise if percobaan >= maks
sleep jeda * percobaan
retry
end
end
# Penggunaan
dengan_retry(maks: 5, jeda: 2, exceptions: [Net::TimeoutError]) do
HTTP.get("https://api.example.com/data")
end
Exception Kustom #
Membuat kelas exception sendiri adalah praktik yang sangat dianjurkan untuk aplikasi yang serius. Exception kustom membuat kode lebih ekspresif dan memungkinkan penanganan yang lebih presisi.
Hierarki Exception Kustom #
# Kelas dasar untuk semua error aplikasi
class AplikasiError < StandardError
attr_reader :kode
def initialize(pesan, kode: nil)
super(pesan)
@kode = kode
end
end
# Error domain spesifik
class ValidationError < AplikasiError
attr_reader :field, :nilai
def initialize(field, nilai, pesan)
super("Validasi gagal untuk '#{field}': #{pesan}", kode: "VALIDATION_ERROR")
@field = field
@nilai = nilai
end
end
class AuthenticationError < AplikasiError
def initialize(pesan = "Autentikasi gagal")
super(pesan, kode: "AUTH_ERROR")
end
end
class AuthorizationError < AplikasiError
def initialize(aksi, resource)
super("Tidak diizinkan melakukan '#{aksi}' pada #{resource}", kode: "AUTHZ_ERROR")
end
end
class ResourceNotFoundError < AplikasiError
attr_reader :resource, :id
def initialize(resource, id)
super("#{resource} dengan ID #{id} tidak ditemukan", kode: "NOT_FOUND")
@resource = resource
@id = id
end
end
class ExternalServiceError < AplikasiError
attr_reader :service, :original_error
def initialize(service, original_error)
super("Gagal menghubungi #{service}: #{original_error.message}", kode: "EXTERNAL_ERROR")
@service = service
@original_error = original_error
end
end
Menggunakan Exception Kustom #
class UserService
def temukan!(id)
user = User.find(id)
raise ResourceNotFoundError.new("User", id) unless user
user
end
def perbarui!(id, data)
user = temukan!(id)
data.each do |field, nilai|
validasi_field!(field, nilai)
end
user.update(data)
rescue ResourceNotFoundError
raise # biarkan menyebar ke caller
rescue ValidationError => e
# log validasi tapi biarkan caller yang handle
log.warn("Validasi gagal: #{e.field}=#{e.nilai}")
raise
end
private
def validasi_field!(field, nilai)
case field
when :email
unless nilai.match?(/\A\S+@\S+\.\S+\z/)
raise ValidationError.new(field, nilai, "format email tidak valid")
end
when :umur
unless (0..150).include?(nilai.to_i)
raise ValidationError.new(field, nilai, "harus antara 0 dan 150")
end
end
end
end
# Di controller atau handler
service = UserService.new
begin
service.perbarui!(999, email: "bukan-email")
rescue ResourceNotFoundError => e
puts "[#{e.kode}] #{e.message}" # => [NOT_FOUND] User dengan ID 999 tidak ditemukan
rescue ValidationError => e
puts "[#{e.kode}] Field '#{e.field}': #{e.message}"
rescue AplikasiError => e
puts "[#{e.kode}] Error aplikasi: #{e.message}"
rescue => e
puts "Error sistem yang tidak terduga: #{e.class} — #{e.message}"
end
Backtrace — Melacak Asal Exception #
Backtrace adalah jejak pemanggilan method yang menunjukkan persis di mana exception terjadi dan bagaimana program sampai ke sana:
def level_tiga
raise RuntimeError, "Error di level tiga!"
end
def level_dua
level_tiga
end
def level_satu
level_dua
end
begin
level_satu
rescue => e
puts "Exception: #{e.message}"
puts "\nBacktrace (5 baris pertama):"
puts e.backtrace.first(5).map { |b| " #{b}" }
end
# => Exception: Error di level tiga!
# => Backtrace:
# => program.rb:2:in 'level_tiga'
# => program.rb:6:in 'level_dua'
# => program.rb:10:in 'level_satu'
# => program.rb:14:in '<main>'
Untuk aplikasi produksi, jangan tampilkan backtrace ke pengguna — log ke file atau layanan monitoring:
def tangani_exception_produksi(e)
# Log lengkap untuk developer
File.open("log/error.log", "a") do |f|
f.puts "[#{Time.now}] #{e.class}: #{e.message}"
f.puts e.backtrace.join("\n")
f.puts "---"
end
# Pesan yang aman untuk ditampilkan ke pengguna
"Terjadi kesalahan. Tim kami sudah diberitahu."
end
Cause — Rantai Exception #
Sejak Ruby 2.1, exception bisa punya cause — yaitu exception asli yang memicu exception baru. Ini sangat berguna untuk wrapping exception dari library pihak ketiga:
def hubungi_payment_gateway(jumlah)
begin
# Simulasi error dari gem pihak ketiga
raise Net::TimeoutError, "Connection timed out"
rescue Net::TimeoutError => e
# Wrap ke exception domain kita, tapi pertahankan cause-nya
raise ExternalServiceError.new("PaymentGateway", e)
end
end
begin
hubungi_payment_gateway(150_000)
rescue ExternalServiceError => e
puts "Error: #{e.message}"
puts "Penyebab: #{e.cause&.class} — #{e.cause&.message}"
end
# => Error: Gagal menghubungi PaymentGateway: Connection timed out
# => Penyebab: Net::TimeoutError — Connection timed out
Anti-Pattern Exception yang Harus Dihindari #
# ANTI-PATTERN 1: rescue Exception — menangkap Ctrl+C dan SystemExit!
begin
jalankan_server
rescue Exception => e # ← JANGAN — ini menangkap Interrupt dan SystemExit
puts "Error: #{e.message}"
end
# BENAR: rescue StandardError atau tipe yang lebih spesifik
begin
jalankan_server
rescue StandardError => e
puts "Error: #{e.message}"
end
# ANTI-PATTERN 2: menelan exception tanpa logging — error hilang begitu saja
begin
operasi_penting()
rescue => e
# tidak ada yang dilakukan — bug menjadi tak terlihat!
end
# BENAR: minimal log sebelum melanjutkan
begin
operasi_penting()
rescue => e
log.error("operasi_penting gagal: #{e.class} — #{e.message}")
# lalu putuskan: raise ulang, atau fallback ke nilai default
end
# ANTI-PATTERN 3: rescue terlalu lebar untuk kode yang panjang
begin
langkah1
langkah2
langkah3 # ← jika ini gagal, rescue di bawah tidak tahu langkah mana yang salah
langkah4
rescue => e
puts "Ada yang gagal: #{e.message}"
end
# BENAR: rescue hanya kode yang benar-benar perlu dilindungi
langkah1 # tidak perlu rescue — biar gagal dengan jelas
langkah2
begin
langkah3 # hanya ini yang rawan
rescue => e
tangani_kegagalan_langkah3(e)
end
langkah4
# ANTI-PATTERN 4: raise string — menghasilkan RuntimeError yang kurang informatif
raise "user tidak ditemukan" # ← tipe exception tidak informatif
# BENAR: raise dengan kelas exception yang tepat
raise ResourceNotFoundError.new("User", id)
raise ArgumentError, "ID harus berupa Integer positif"
rescue dalam Konteks Lain #
Selain begin/end dan di dalam method, rescue juga bisa digunakan inline sebagai modifier — mirip if dan unless postfix:
# rescue sebagai ekspresi inline
nilai = Integer(input) rescue nil
# Jika Integer(input) gagal (ArgumentError), nilai menjadi nil
angka = Float(teks) rescue 0.0
hash = JSON.parse(json_string) rescue {}
# Berguna untuk konversi yang bisa gagal
def parse_tanggal(teks)
Date.parse(teks) rescue nil
end
tanggal = parse_tanggal("2024-01-15") # => Date object
tanggal = parse_tanggal("bukan tanggal") # => nil (bukan exception)
Inlinerescuememang ringkas, tapi gunakan dengan hati-hati. Ia menangkap semuaStandardError— termasuk error yang mungkin tidak kamu antisipasi. Jika hanya ingin menangkap tipe tertentu, tetap gunakanbegin/rescue/endyang eksplisit.
Pola Exception yang Idiomatik #
Pola Fail Fast #
Untuk validasi, lebih baik gagal sesegera mungkin daripada melanjutkan dengan state yang tidak valid:
# ANTI-PATTERN: validasi yang lambat — terus jalan sampai akhirnya crash
def proses_pesanan(pesanan)
if pesanan
if pesanan[:item] && !pesanan[:item].empty?
if pesanan[:total] && pesanan[:total] > 0
# baru mulai proses di sini — terlalu dalam
buat_invoice(pesanan)
end
end
end
end
# BENAR: fail fast dengan raise — logika utama tidak bersarang
def proses_pesanan(pesanan)
raise ArgumentError, "Pesanan tidak boleh nil" unless pesanan
raise ArgumentError, "Pesanan harus punya item" if pesanan[:item].to_a.empty?
raise ArgumentError, "Total harus lebih dari nol" unless pesanan[:total].to_i > 0
# logika utama — tidak ada nesting
buat_invoice(pesanan)
end
Pola Exception sebagai Kontrol Alur — Hindari! #
Exception di Ruby cukup cepat, tapi tetap ada overhead. Yang lebih penting, menggunakan exception untuk kontrol alur normal membuat kode sulit dibaca:
# ANTI-PATTERN: exception untuk kontrol alur biasa
def cari_user(email)
begin
User.find_by_email!(email) # raise jika tidak ditemukan
rescue RecordNotFound
nil
end
end
# BENAR: gunakan method yang mengembalikan nil jika tidak ditemukan
def cari_user(email)
User.find_by_email(email) # kembalikan nil jika tidak ditemukan
end
# Exception untuk kondisi LUAR BIASA — bukan alur normal
def cari_user!(email)
User.find_by_email(email) or raise ResourceNotFoundError.new("User", email)
end
Ringkasan #
- Hierarki exception menentukan apa yang ditangkap —
rescue => ehanya menangkapStandardErrorke bawah, bukanException. Ini perilaku yang diinginkan.- Jangan rescue
Exception— ia menangkapInterrupt(Ctrl+C),SystemExit, danNoMemoryErroryang seharusnya tidak ditangkap sembarangan.elseuntuk kode yang hanya jalan jika sukses,ensureuntuk cleanup — keduanya saling melengkapi dalam satu blokbegin/rescue.- rescue di level method tanpa
begin/end— Ruby mengizinkan ini dan hasilnya lebih bersih daribegin/rescue/endeksplisit di dalam method.- Batasi
retrydengan counter dan exponential backoff —retrytanpa batas adalah infinite loop yang menunggu terjadi.- Buat hierarki exception kustom — turunkan dari
StandardError(atau kelas aplikasi sendiri), tambahkan atribut yang relevan, dan beri kode error yang bermakna.- Selalu log sebelum menelan exception — exception yang diabaikan diam-diam adalah bug yang tersembunyi. Minimal log pesan dan kelas exception-nya.
rescuetipe yang paling spesifik terlebih dahulu — sama sepertielsif, rescue yang lebih spesifik harus di atas rescue yang lebih umum.- Exception
causeuntuk wrapping — saat mengubah exception library pihak ketiga menjadi exception domain kamu,causemenyimpan exception asli untuk debugging.- Jangan gunakan exception untuk kontrol alur normal — exception adalah untuk kondisi luar biasa, bukan pengganti
if/elseatau nilai kembaliannil.