Eksepsi

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 --> I

else — 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 jumlah retry dengan counter — retry tanpa 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)
Inline rescue memang ringkas, tapi gunakan dengan hati-hati. Ia menangkap semua StandardError — termasuk error yang mungkin tidak kamu antisipasi. Jika hanya ingin menangkap tipe tertentu, tetap gunakan begin/rescue/end yang 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 ditangkaprescue => e hanya menangkap StandardError ke bawah, bukan Exception. Ini perilaku yang diinginkan.
  • Jangan rescue Exception — ia menangkap Interrupt (Ctrl+C), SystemExit, dan NoMemoryError yang seharusnya tidak ditangkap sembarangan.
  • else untuk kode yang hanya jalan jika sukses, ensure untuk cleanup — keduanya saling melengkapi dalam satu blok begin/rescue.
  • rescue di level method tanpa begin/end — Ruby mengizinkan ini dan hasilnya lebih bersih dari begin/rescue/end eksplisit di dalam method.
  • Batasi retry dengan counter dan exponential backoffretry tanpa 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.
  • rescue tipe yang paling spesifik terlebih dahulu — sama seperti elsif, rescue yang lebih spesifik harus di atas rescue yang lebih umum.
  • Exception cause untuk wrapping — saat mengubah exception library pihak ketiga menjadi exception domain kamu, cause menyimpan exception asli untuk debugging.
  • Jangan gunakan exception untuk kontrol alur normal — exception adalah untuk kondisi luar biasa, bukan pengganti if/else atau nilai kembalian nil.

← Sebelumnya: Interface   Berikutnya: List →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact