Mengetahui sintaks try/except belum sama dengan tahu di mana harus menempatkannya. Kode yang menangkap exception di setiap lapisan justru sering lebih sulit di-debug daripada kode yang membiarkan error mengalir. Pada Chapter penutup Section ini kita akan menyusun strategi error handling: menentukan lapisan yang tepat untuk menangani, membangkitkan error sedini mungkin, tidak pernah menelan error, serta mencatat dan menerjemahkan error di batas antar-lapisan.

Tangani di Lapisan yang Bisa Bertindak #

Aturan utamanya sederhana: tangkap exception hanya di lapisan yang tahu respons terbaiknya. Lapisan lain cukup membiarkan exception naik ke pemanggil. Perhatikan tiga lapisan berikut:

python
def baca_stok(gudang, produk):
    return gudang[produk]

def tampilkan_stok(gudang, produk):
    print(f"Stok {produk}: {baca_stok(gudang, produk)}")

def main():
    gudang = {"kopi": 12, "teh": 8}
    try:
        tampilkan_stok(gudang, "susu")
    except KeyError:
        print("Produk belum terdaftar, tampilkan form tambah produk")

main()

Output yang Dihasilkan:

text
Produk belum terdaftar, tampilkan form tambah produk

Mari kita telaah apa yang terjadi pada baris-baris kode di atas:

  1. Baris 1–2 (baca_stok): lapisan terbawah hanya membaca data. Function ini tidak tahu apa yang pantas dilakukan saat produk tidak ada — menampilkan pesan? membuat data? Karena itu, function ini tidak punya try sama sekali.
  2. Baris 4–5 (tampilkan_stok): lapisan tengah juga tidak mengambil keputusan; exception hanya melewatinya.
  3. Baris 9–12 (blok try di main()): KeyError dari baca_stok() naik dua lapisan dan baru ditangkap di main() — satu-satunya tempat yang tahu aksi bisnis yang tepat: menampilkan form pendaftaran produk baru.

Alur keputusannya dirangkum diagram berikut:

Fail Fast #

Prinsip fail fast: laporkan kegagalan di momen paling awal, bukan biarkan data buruk menjalar. Konfigurasi yang kurang, misalnya, sebaiknya menolak aplikasi untuk menyala alih-alih membuatnya crash di tengah transaksi jam 3 pagi:

python
def muat_konfigurasi(konfigurasi):
    for kunci in ("host", "port"):
        if kunci not in konfigurasi:
            raise ValueError(f"konfigurasi wajib: {kunci!r}")
    print("Konfigurasi lengkap, aplikasi siap berjalan")

muat_konfigurasi({"host": "localhost", "port": 8000})

try:
    muat_konfigurasi({"host": "localhost"})
except ValueError as e:
    print("Aplikasi menolak start:", e)

Output yang Dihasilkan:

text
Konfigurasi lengkap, aplikasi siap berjalan
Aplikasi menolak start: konfigurasi wajib: 'port'

Pada pemanggilan pertama, seluruh kunci hadir sehingga validasi lolos. Pada pemanggilan kedua, kunci port hilang dan function muat_konfigurasi() membangkitkan ValueError saat itu juga — bukan saat koneksi database pertama gagal dua jam kemudian.

Jangan Menelan Error #

Pola paling berbahaya dalam penanganan error: except diikuti pass. Error tertangkap, tidak ada yang dikerjakan, tidak ada jejak yang tersisa:

python
def kirim_notifikasi_email(alamat):
    raise ValueError(f"alamat email tidak valid: {alamat}")

def selesaikan_pesanan(pesanan):
    try:
        kirim_notifikasi_email(pesanan["email"])
    except Exception:
        pass
    print(f"Pesanan {pesanan['id']} selesai")

selesaikan_pesanan({"id": 1024, "email": "[email protected]"})

Output yang Dihasilkan:

text
Pesanan 1024 selesai

Pesanan tampak selesai sempurna padahal notifikasi email tidak pernah terkirim. Bila pelanggan komplain “saya tidak menerima email”, tidak ada log, tidak ada traceback, tidak ada petunjuk apa pun — bug menjadi hantu yang tidak meninggalkan jejak.

Solusinya: catat kejadian dengan module logging, lalu putuskan kebijakannya — lanjut tanpa notifikasi, atau batalkan pesanan. Method logging.exception() otomatis menyertakan traceback dari exception yang sedang aktif. Simpan sebagai notifikasi.py, lalu jalankan dengan python3 notifikasi.py:

python
import logging
import sys

logging.basicConfig(
    level=logging.INFO,
    format="%(levelname)s: %(message)s",
    stream=sys.stdout,
)

def kirim_notifikasi_email(alamat):
    raise ValueError(f"alamat email tidak valid: {alamat}")

def selesaikan_pesanan(pesanan):
    try:
        kirim_notifikasi_email(pesanan["email"])
    except ValueError:
        logging.exception("Gagal kirim email untuk pesanan %s", pesanan["id"])
    print(f"Pesanan {pesanan['id']} selesai")

selesaikan_pesanan({"id": 1024, "email": "[email protected]"})

Output yang Dihasilkan:

text
ERROR: Gagal kirim email untuk pesanan 1024
Traceback (most recent call last):
  File "notifikasi.py", line 15, in selesaikan_pesanan
    kirim_notifikasi_email(pesanan["email"])
  File "notifikasi.py", line 11, in kirim_notifikasi_email
    raise ValueError(f"alamat email tidak valid: {alamat}")
ValueError: alamat email tidak valid: [email protected]
Pesanan 1024 selesai

Sekarang keputusan bisnisnya tersampaikan jelas: notifikasi gagal dicatat lengkap dengan traceback, dan pesanan tetap dianggap selesai. Mari kita telaah bagian-bagian kuncinya pada notifikasi.py:

  1. Baris 4–8 (logging.basicConfig(...)): konfigurasi level log dan format pesan disiapkan sekali di awal program. Parameter stream=sys.stdout hanya mengarahkan log agar mudah dilihat di contoh ini; secara default module logging menulis ke stderr.
  2. Baris 17 (logging.exception(...)): dipanggil dari dalam blok except, method ini mencatat pesan level ERROR beserta traceback exception yang sedang aktif — tanpa perlu menyusun traceback manual.
  3. Baris 18 (print(f"Pesanan ...")): pesanan tetap dinyatakan selesai — keputusan sadar bahwa notifikasi bukan syarat keberhasilan pesanan, dan keputusan tersebut kini tercatat jejaknya.

Pembahasan logging secara menyeluruh menempuh Section Debugging dan Logging pada Chapter Module logging.

Mengubah Error Internal Menjadi Error Domain #

Lapisan paling luar sebuah API sebaiknya berbicara bahasa pemanggilnya, bukan bahasa implementasinya. Exception teknis diterjemahkan menjadi exception domain, dan rantai from e (Chapter Exception Chaining) menjaga jejak aslinya:

python
class DataPelangganError(Exception):
    pass

def baca_usia(rekam):
    try:
        usia = int(rekam["usia"])
    except KeyError as e:
        raise DataPelangganError("kolom 'usia' hilang dari data") from e
    except ValueError as e:
        raise DataPelangganError(f"usia harus angka: {rekam['usia']!r}") from e
    return usia

def tampilkan_profil(rekam):
    try:
        print("Usia pelanggan:", baca_usia(rekam))
    except DataPelangganError as e:
        print("Data pelanggan bermasalah:", e)
        print("Penyebab asli           :", repr(e.__cause__))

tampilkan_profil({"nama": "Budi", "usia": "tujuh belas"})

Output yang Dihasilkan:

text
Data pelanggan bermasalah: usia harus angka: 'tujuh belas'
Penyebab asli           : ValueError("invalid literal for int() with base 10: 'tujuh belas'")

Function tampilkan_profil() bekerja dengan satu jenis kegagalan domain — DataPelangganError — tanpa perlu tahu bahwa di dalamnya ada KeyError dan ValueError. Detail teknis tetap bisa ditelusuri lewat __cause__ saat debugging.

Checklist Kualitas Produksi #

Pertanyaan pemeriksaanJawaban yang sehat
Exception apa saja yang masuk akal ditangkap di sini?hanya yang punya rencana pemulihan konkret
Apa yang dilakukan handler saat error?pulihkan, catat log, atau terjemahkan — bukan pass
Apakah pesan error membantu debugging?menyebut nilai, kunci, atau ID yang bermasalah
Apakah error internal bocor ke pengguna akhir?tidak — diterjemahkan menjadi error domain di batas API
Apakah kegagalan terdeteksi sedini mungkin?validasi dilakukan di pintu masuk data
Apakah log tersedia dengan traceback lengkap?ya, melalui logging.exception()
Apakah Ctrl+C dan sys.exit() masih bekerja?ya — handler tidak menangkap BaseException

Tujuh butir pada tabel tersebut adalah pertanyaan pemeriksaan diri sebelum kode berangkat ke produksi. Bersama seluruh Chapter pada Section ini — dari anatomi exception sampai pohon hierarki class — kita kini punya bekal lengkap untuk menangani error secara sadar: tahu kapan menangkap, tahu kapan melepas, dan tahu kapan bersuara.