Error database bukan peristiwa luar biasa — ia rutinitas. Pengguna mendaftar dengan email yang sudah terdaftar, kode membaca kolom yang salah ketik, dua proses saling mengunci, koneksi terputus di tengah rilis. Aplikasi yang sehat tidak runtuh oleh peristiwa tersebut; aplikasi menangkap error, membaca klasifikasinya, dan memutuskan reaksi yang tepat: tolak dengan pesan ramah, coba ulang, atau laporkan sebagai bug. Kunci semua keputusan itu adalah satu kode lima karakter bernama SQLSTATE yang dikirim server pada setiap error, dan yang — berbeda dengan teks pesan — dijamin stabil lintas versi maupun driver. Chapter ini membedah anatomi error PostgreSQL, memetakan kode yang paling sering muncul, lalu menangkapnya secara terklasifikasi dari psycopg dan node-postgres.

Anatomi Error PostgreSQL #

Setiap error PostgreSQL membawa paket informasi yang lengkap, bukan sekadar teks pesan. Format verbose psql menampilkan seluruhnya — baris \set VERBOSITY verbose di awal berkas mengaktifkan mode itu sebelum sebuah INSERT duplikat sengaja dibuat gagal:

-- Anatomi error PostgreSQL: aktifkan format verbose psql supaya
-- kode SQLSTATE dan konteks lainnya ikut tampil.
\set VERBOSITY verbose

CREATE TABLE anggota (
    id    integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama  text NOT NULL,
    email text NOT NULL UNIQUE
);

INSERT INTO anggota (nama, email) VALUES ('Budi', '[email protected]');

-- Baris ini gagal karena email sudah dipakai; psql berhenti di sini
-- dan seluruh anatomi error tampil sebagai output berkas ini.
INSERT INTO anggota (nama, email) VALUES ('Budi Lagi', '[email protected]');

Output yang Dihasilkan:

text
psql:anatomi-error.sql:15: ERROR:  23505: duplicate key value violates unique constraint "anggota_email_key"
DETAIL:  Key (email)=([email protected]) already exists.
SCHEMA NAME:  public
TABLE NAME:  anggota
CONSTRAINT NAME:  anggota_email_key
LOCATION:  _bt_check_unique, nbtinsert.c:666

Mari kita baca anatomi yang tampil pada output di atas:

  1. ERROR: 23505: — dua informasi paling penting berdampingan: severity (ERROR) dan kode SQLSTATE (23505, unique violation). Kode inilah yang stabil dan layak dijadikan dasar keputusan program.
  2. Baris DETAIL: — konteks spesifik kejadian: Key (email)=([email protected]) already exists menyebut kolom dan nilai yang berkonflik.
  3. SCHEMA NAME, TABLE NAME, CONSTRAINT NAME — nama objek tempat error terjadi; anggota_email_key adalah nama constraint unik yang dilanggar — emas untuk logging karena langsung menunjuk lokasi masalah.
  4. LOCATION: — posisi kode di sumber server PostgreSQL yang menimbulkan error; berguna bagi pengembang server, jarang dipakai aplikasi.

Dua pernyataan pertama berkas itu (CREATE TABLE, INSERT 0 1) sukses sebelum error terjadi; output hanya menampilkan error karena psql dengan ON_ERROR_STOP berhenti tepat di baris gagal.

Kode SQLSTATE yang Sering Ditemui #

Daftar lengkapnya hidup di Appendix A dokumentasi PostgreSQL; tabel berikut memuat kode yang paling sering dijumpai aplikasi nyata:

SQLSTATENamaPemicuReaksi umum
23505unique_violationINSERT/UPDATE melanggar unique/PKTolak dengan pesan “sudah ada”
23503foreign_key_violationReferensi ke baris yang tidak adaTolak, periksa data induk
23502not_null_violationKolom NOT NULL menerima NULLTolak, lengkapi input
23514check_violationNilai gagal CHECKTolak dengan aturan yang dilanggar
22012division_by_zeroPembagian dengan nolBug — perbaiki query
42P01undefined_tableTabel tidak adaBug/skema tidak termigrasi
42703undefined_columnKolom tidak adaBug — salah ketik atau skema telat
40001serialization_failureTransaksi SERIALIZABLE bentrokRetry dengan data segar
40P01deadlock_detectedDeadlock, satu transaksi dibunuh serverRetry seluruh transaksi
55P03lock_not_availableNOWAIT/lock_timeout kena lockRetry atau antre
08006connection_failureKoneksi gagal/terputusRetry koneksi dengan backoff

Exception Terklasifikasi di psycopg #

psycopg 3 menerjemahkan SQLSTATE menjadi class exception bernama di modul psycopg.errors: kode 23505 menjadi errors.UniqueViolation, 23503 menjadi errors.ForeignKeyViolation, dan seterusnya — semuanya turunan psycopg.Error yang menyimpan kode aslinya di atribut .sqlstate. Skrip berikut menyiapkan dua tabel dengan ragam constraint, lalu menjalankan delapan pernyataan: satu subset gagal oleh sebab yang berbeda-beda, dan setiap sebab tertangkap oleh class yang tepat:

# Menangkap error PostgreSQL sebagai exception terklasifikasi di psycopg.
# Setiap class di psycopg.errors punya atribut .sqlstate; .diag membawa
# konteks tambahan (nama constraint, nama kolom) dari server.
import psycopg
from psycopg import errors

conn = psycopg.connect()
cur = conn.cursor()

cur.execute("""
    CREATE TABLE pelanggan (
        id    integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
        nama  text NOT NULL,
        email text NOT NULL UNIQUE,
        CHECK (length(nama) >= 3)
    )
""")
cur.execute("""
    CREATE TABLE pesanan (
        id           integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
        pelanggan_id integer NOT NULL REFERENCES pelanggan (id)
    )
""")
conn.commit()


def jalankan(label, query, params):
    """Jalankan satu pernyataan dan tangkap errornya per klasifikasi."""
    try:
        with conn.transaction():
            cur.execute(query, params)
        print(f"OK    : {label}")
    except errors.UniqueViolation as e:
        print(f"TOLAK : {label} -- email sudah dipakai "
              f"(sqlstate {e.sqlstate}, constraint {e.diag.constraint_name})")
    except errors.CheckViolation as e:
        print(f"TOLAK : {label} -- nama minimal 3 karakter "
              f"(sqlstate {e.sqlstate}, constraint {e.diag.constraint_name})")
    except errors.NotNullViolation as e:
        print(f"TOLAK : {label} -- kolom {e.diag.column_name} wajib diisi "
              f"(sqlstate {e.sqlstate})")
    except errors.ForeignKeyViolation as e:
        print(f"TOLAK : {label} -- pelanggan tidak ditemukan "
              f"(sqlstate {e.sqlstate}, constraint {e.diag.constraint_name})")
    except errors.UndefinedColumn as e:
        print(f"BUG   : {label} -- kolom tidak ada "
              f"(sqlstate {e.sqlstate})")
    except errors.DivisionByZero as e:
        print(f"BUG   : {label} -- pembagian dengan nol "
              f"(sqlstate {e.sqlstate})")
    except psycopg.Error as e:
        # Kode yang tidak kita antisipasi: catat sqlstate-nya,
        # bungkus ulang sebagai error aplikasi.
        print(f"ERROR : {label} -- sqlstate {e.sqlstate}")


jalankan("Budi mendaftar",
         "INSERT INTO pelanggan (nama, email) VALUES (%s, %s)",
         ("Budi", "[email protected]"))
jalankan("Budi Duplikat mendaftar",
         "INSERT INTO pelanggan (nama, email) VALUES (%s, %s)",
         ("Budi Duplikat", "[email protected]"))
jalankan("An mendaftar",
         "INSERT INTO pelanggan (nama, email) VALUES (%s, %s)",
         ("An", "[email protected]"))
jalankan("pelanggan tanpa nama",
         "INSERT INTO pelanggan (nama, email) VALUES (%s, %s)",
         (None, "[email protected]"))
jalankan("pesanan untuk pelanggan 1",
         "INSERT INTO pesanan (pelanggan_id) VALUES (%s)", (1,))
jalankan("pesanan untuk pelanggan 99",
         "INSERT INTO pesanan (pelanggan_id) VALUES (%s)", (99,))
jalankan("query dengan salah ketik kolom",
         "SELECT emel FROM pelanggan", ())
jalankan("query pembagian nol",
         "SELECT 1 / jumlah_nol FROM (VALUES (0)) AS t (jumlah_nol)", ())

cur.close()
conn.close()

Output yang Dihasilkan:

text
OK    : Budi mendaftar
TOLAK : Budi Duplikat mendaftar -- email sudah dipakai (sqlstate 23505, constraint pelanggan_email_key)
TOLAK : An mendaftar -- nama minimal 3 karakter (sqlstate 23514, constraint pelanggan_nama_check)
TOLAK : pelanggan tanpa nama -- kolom nama wajib diisi (sqlstate 23502)
OK    : pesanan untuk pelanggan 1
TOLAK : pesanan untuk pelanggan 99 -- pelanggan tidak ditemukan (sqlstate 23503, constraint pesanan_pelanggan_id_fkey)
BUG   : query dengan salah ketik kolom -- kolom tidak ada (sqlstate 42703)
BUG   : query pembagian nol -- pembagian dengan nol (sqlstate 22012)

Mari kita bedah bagian-bagian penting pola tersebut:

  1. with conn.transaction(): di sekitar cur.execute — setiap error membatalkan transaksinya sendiri dan meninggalkan koneksi bersih untuk percobaan berikutnya; tanpa bungkus ini, koneksi tersangkut dalam status aborted setelah error pertama.
  2. Urutan except dari spesifik ke umum — errors.UniqueViolation diperiksa sebelum psycopg.Error; class paling umum di akhir berfungsi sebagai jaring pengaman untuk kode yang tidak kita antisipasi.
  3. e.sqlstate dan e.diag.constraint_name — kode dan nama constraint diambil dari atribut terstruktur, bukan dengan mengurai teks pesan; e.diag.column_name pada NotNullViolation bahkan menyebut kolom mana yang kosong.
  4. Label reaksi pada output — TOLAK untuk pelanggaran data dari pengguna (input ditolak dengan pesan ramah), BUG untuk kesalahan kode yang harus diperbaiki programmer: klasifikasi yang sama, penanganan yang berbeda.

err.code di node-postgres #

node-postgres tidak memetakan SQLSTATE ke class exception — semua error database datang sebagai Error biasa, dan kode SQLSTATE tersedia di properti err.code (string). Pola penerapannya: bandingkan err.code dengan kode yang diantisipasi, serahkan sisanya ke jalur error umum:

javascript tangkap-error-node.mjs
// Klasifikasi error PostgreSQL di node-postgres lewat err.code.
// err.code berisi SQLSTATE (string); err.constraint dan err.detail
// menyertakan konteks dari server.
import pg from "pg";

const client = new pg.Client();
await client.connect();

await client.query(`
    CREATE TABLE pelanggan_node (
        id    integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
        nama  text NOT NULL,
        email text NOT NULL UNIQUE
    )
`);
await client.query(
  "INSERT INTO pelanggan_node (nama, email) VALUES ($1, $2)",
  ["Budi", "[email protected]"],
);

async function daftarPelanggan(nama, email) {
  try {
    await client.query(
      "INSERT INTO pelanggan_node (nama, email) VALUES ($1, $2)",
      [nama, email],
    );
    console.log(`OK    : ${nama} terdaftar`);
  } catch (err) {
    if (err.code === "23505") {
      console.log(`TOLAK : ${nama} gagal -- email ${email} sudah dipakai`);
      console.log(`        sqlstate=${err.code}, constraint=${err.constraint}`);
    } else {
      console.log(`ERROR : sqlstate=${err.code} -- ${err.message}`);
    }
  }
}

await daftarPelanggan("Siti", "[email protected]");
await daftarPelanggan("Budi Lagi", "[email protected]");

await client.end();

Output yang Dihasilkan:

text
OK    : Siti terdaftar
TOLAK : Budi Lagi gagal -- email [email protected] sudah dipakai
        sqlstate=23505, constraint=pelanggan_node_email_key

Output memperlihatkan hasil yang setara dengan versi psycopg: pendaftaran Siti berhasil, pendaftaran Budi Lagi ditolak, dan properti err.code berisi 23505 persis seperti e.sqlstate — kode yang sama, dibawa lewat jalur berbeda. Properti err.constraint juga hadir, memuat pelanggan_node_email_key, sehingga pola “klasifikasi dulu, pesan ramah kemudian” tetap bisa diterapkan sama di kedua driver.

Retryable atau Fatal #

Tidak semua error setara: sebagian adalah kondisi sementara yang hilang bila diulang, sebagian adalah kepastian yang akan gagal lagi sebanyak apa pun diulang. Klasifikasi ini menentukan arsitektur penanganan error aplikasi:

GolonganSQLSTATESifatPenanganan
Sementara40001, 40P01, 55P03, 08003, 08006Bentrok/kebuntuan/putus yang hilang dengan waktuRetry dengan backoff eksponensial, batasi jumlah percobaan
Pelanggaran data23505, 23503, 23502, 23514Input pengguna melanggar aturanTolak, balas dengan pesan ramah tanpa retry
Bug aplikasi/skema42703, 42P01, 22012Akan gagal identik bila diulangLaporkan sebagai error internal, perbaiki kodenya

Retry untuk golongan sementara selalu mengulang seluruh transaksi dari awal — bukan hanya pernyataan terakhir — karena hasil kerja transaksi yang gagal sudah tidak bisa dipercaya. Jeda antar percobaan sebaiknya naik eksponensial (misalnya 50 ms, 100 ms, 200 ms) dengan batas atas percobaan, agar aplikasi tidak menghantani server yang sedang sehati. Loop retry lengkapnya — lengkap dengan transaksi yang diulang dan keputusan bisnis yang diambil ulang dari data segar — kita bangun di chapter berikutnya.

Membungkus Error dan Disiplin Logging #

Error database sebaiknya tidak bocor mentah ke lapisan antarmuka: baris TOLAK pada contoh kita adalah bentuk sederhana dari error aplikasi — EmailSudahDipakai, PelangganTidakDitemukan — yang pesannya dikontrol aplikasi, bukan server. Aplikasi berlapis biasanya membungkus psycopg.Error ke dalam exception domain di batas repository, sehingga lapisan atas tidak perlu tahu driver apa yang dipakai di bawah.

Disiplin logging-nya mengikuti prinsip yang sama dengan Chapter SQL Injection : log yang berguna itu minim. Catat sqlstate, nama constraint, nama tabel, dan ID korrelasi permintaan — jangan menyalin seluruh DETAIL yang berisi nilai milik pengguna, dan jangan menempelkan teks SQL lengkap beserta parameternya ke log.

Satu bagian masih tertinggal dari rantai penanganan error: error seperti 40001 dan 40P01 terjadi di dalam transaksi, dan reaksi yang benar — ROLLBACK, ulangi, ambil keputusan dari data segar — hanya bisa ditulis bila aplikasi memegang kendali penuh atas batas transaksinya. Cara psycopg mengelola BEGIN, COMMIT, ROLLBACK, savepoint, dan loop retry dari kode aplikasi adalah topik penutup section ini, Transaksi dalam Aplikasi.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

anatomi-error.sql

Unduh berkas
sql
-- Anatomi error PostgreSQL: aktifkan format verbose psql supaya
-- kode SQLSTATE dan konteks lainnya ikut tampil.
\set VERBOSITY verbose

CREATE TABLE anggota (
    id    integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama  text NOT NULL,
    email text NOT NULL UNIQUE
);

INSERT INTO anggota (nama, email) VALUES ('Budi', '[email protected]');

-- Baris ini gagal karena email sudah dipakai; psql berhenti di sini
-- dan seluruh anatomi error tampil sebagai output berkas ini.
INSERT INTO anggota (nama, email) VALUES ('Budi Lagi', '[email protected]');

tangkap-error-psycopg.py

Unduh berkas
python
# Menangkap error PostgreSQL sebagai exception terklasifikasi di psycopg.
# Setiap class di psycopg.errors punya atribut .sqlstate; .diag membawa
# konteks tambahan (nama constraint, nama kolom) dari server.
import psycopg
from psycopg import errors

conn = psycopg.connect()
cur = conn.cursor()

cur.execute("""
    CREATE TABLE pelanggan (
        id    integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
        nama  text NOT NULL,
        email text NOT NULL UNIQUE,
        CHECK (length(nama) >= 3)
    )
""")
cur.execute("""
    CREATE TABLE pesanan (
        id           integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
        pelanggan_id integer NOT NULL REFERENCES pelanggan (id)
    )
""")
conn.commit()


def jalankan(label, query, params):
    """Jalankan satu pernyataan dan tangkap errornya per klasifikasi."""
    try:
        with conn.transaction():
            cur.execute(query, params)
        print(f"OK    : {label}")
    except errors.UniqueViolation as e:
        print(f"TOLAK : {label} -- email sudah dipakai "
              f"(sqlstate {e.sqlstate}, constraint {e.diag.constraint_name})")
    except errors.CheckViolation as e:
        print(f"TOLAK : {label} -- nama minimal 3 karakter "
              f"(sqlstate {e.sqlstate}, constraint {e.diag.constraint_name})")
    except errors.NotNullViolation as e:
        print(f"TOLAK : {label} -- kolom {e.diag.column_name} wajib diisi "
              f"(sqlstate {e.sqlstate})")
    except errors.ForeignKeyViolation as e:
        print(f"TOLAK : {label} -- pelanggan tidak ditemukan "
              f"(sqlstate {e.sqlstate}, constraint {e.diag.constraint_name})")
    except errors.UndefinedColumn as e:
        print(f"BUG   : {label} -- kolom tidak ada "
              f"(sqlstate {e.sqlstate})")
    except errors.DivisionByZero as e:
        print(f"BUG   : {label} -- pembagian dengan nol "
              f"(sqlstate {e.sqlstate})")
    except psycopg.Error as e:
        # Kode yang tidak kita antisipasi: catat sqlstate-nya,
        # bungkus ulang sebagai error aplikasi.
        print(f"ERROR : {label} -- sqlstate {e.sqlstate}")


jalankan("Budi mendaftar",
         "INSERT INTO pelanggan (nama, email) VALUES (%s, %s)",
         ("Budi", "[email protected]"))
jalankan("Budi Duplikat mendaftar",
         "INSERT INTO pelanggan (nama, email) VALUES (%s, %s)",
         ("Budi Duplikat", "[email protected]"))
jalankan("An mendaftar",
         "INSERT INTO pelanggan (nama, email) VALUES (%s, %s)",
         ("An", "[email protected]"))
jalankan("pelanggan tanpa nama",
         "INSERT INTO pelanggan (nama, email) VALUES (%s, %s)",
         (None, "[email protected]"))
jalankan("pesanan untuk pelanggan 1",
         "INSERT INTO pesanan (pelanggan_id) VALUES (%s)", (1,))
jalankan("pesanan untuk pelanggan 99",
         "INSERT INTO pesanan (pelanggan_id) VALUES (%s)", (99,))
jalankan("query dengan salah ketik kolom",
         "SELECT emel FROM pelanggan", ())
jalankan("query pembagian nol",
         "SELECT 1 / jumlah_nol FROM (VALUES (0)) AS t (jumlah_nol)", ())

cur.close()
conn.close()