Aplikasi yang sehat bukan aplikasi yang tidak pernah mendapat error database — aplikasi seperti itu tidak ada. Server bisa mati, password bisa berganti saat rotasi, dua pengguna bisa memasukkan email yang sama pada detik yang sama, dan dua transaksi bisa saling mengunci diam-diam. Yang memisahkan aplikasi sehat dari yang rapuh adalah apa yang terjadi setelah error tiba: apakah pengguna melihat pesan yang membingungkan (atau halaman kosong), ataukah pesan yang jujur dan tetap sopan; apakah detail teknisnya hilang begitu, ataukah tercatat lengkap untuk yang memperbaiki. Chapter penutup section ini memetakan error MySQL yang paling sering dijumpai aplikasi, lalu menangani tiga di antaranya secara nyata lewat PyMySQL — termasuk satu pola yang wajib dihafal: deadlock itu bukan kegagalan, melainkan perintah untuk mencoba lagi.

Kode Error dan SQLSTATE #

Setiap error MySQL membawa dua identitas. Error code — angka empat digit seperti 1062 — spesifik milik MySQL dan sangat detail: dua pelanggaran constraint yang berbeda (duplikat kunci versus foreign key gagal) mendapat kode berbeda. SQLSTATE — kode lima karakter seperti 23000 — mengikuti standar SQL lintas-database dan lebih kasar: semua pelanggaran integritas data berbagi kelas 23, kelas 40 berarti rollback berseri (serialisasi, termasuk deadlock), kelas 42 berarti kesalahan sintaks atau nama. Klien mysql menampilkan keduanya berdampingan — ERROR 1062 (23000) — dan driver aplikasi kita membawa keduanya juga: PyMySQL menaruh error code di err.args[0].

Kapan memakai yang mana? Kode MySQL (1062) tepat untuk keputusan penanganan yang spesifik — “baris sudah ada, tampilkan pesan”. SQLSTATE tepat untuk penanganan per-kelas yang portabel — “semua error integritas, batalkan form” — terutama bila suatu saat database diganti dan kode MySQL tidak berlaku lagi. Keduanya selalu tersedia; pilihan bergantung pada seberapa spesifik reaksi kita.

Peta Error Umum dan Tindakannya #

KodeSQLSTATEWujudPenyebab umumTindakan aplikasi
104528000Access denieduser/password salah, host tidak diizinkanperiksa kredensial dan hak akses
2003HY000Can’t connect to MySQL serverserver mati, port salah, firewallcek status server, host, port; tampilkan mode terdegradasi
104942000Unknown databasenama database salah atau belum dibuatperiksa nama; jalankan migration bila environment baru
106223000Duplicate entryPRIMARY atau UNIQUE bentrokpesan ramah (“sudah terdaftar”) atau ON DUPLICATE KEY UPDATE
145223000Cannot add or update a child rowforeign key: induk belum adavalidasi relasi; masukkan induk dulu
145123000Cannot delete or update a parent rowforeign key: baris masih dipakai anaktolak hapus dengan pesan jelas, atau ON DELETE CASCADE
121340001Deadlock founddua transaksi saling menunggu kunciulangi transaksi dari awal — aman dan lazim
1205HY000Lock wait timeoutmenunggu kunci terlalu lamaperpendek transaksi; periksa query lambat
2006HY000MySQL server has gone awaykoneksi idle diputus serverreconnect atau pool dengan health check

Tiga baris pertama adalah error koneksi — aplikasi bahkan belum sempat bicara SQL; sisanya error statement — koneksi sehat, satu perintah tertolak. beda keduanya menentukan letak penanganan: error koneksi ditangkap di sekitar connect(), error statement di sekitar execute().

Menyiapkan Data Latihan #

Skema kita memakai tiga tabel dengan dua foreign key — cukup untuk memicu error duplikat dan error relasi sekaligus:

sql
DROP DATABASE IF EXISTS belajar_app;
CREATE DATABASE belajar_app;
USE belajar_app;

CREATE TABLE produk (
  id    INT PRIMARY KEY,
  nama  VARCHAR(100) NOT NULL,
  harga INT NOT NULL
);

INSERT INTO produk (id, nama, harga) VALUES
  (1, 'Kopi Toraja', 52000),
  (2, 'Teh Melati', 28000);

CREATE TABLE pelanggan (
  id    INT PRIMARY KEY,
  nama  VARCHAR(100) NOT NULL,
  email VARCHAR(150) NOT NULL UNIQUE
);

INSERT INTO pelanggan (id, nama, email) VALUES
  (1, 'Budi Santoso', '[email protected]');

CREATE TABLE pesanan (
  id           INT PRIMARY KEY,
  pelanggan_id INT NOT NULL,
  produk_id    INT NOT NULL,
  jumlah       INT NOT NULL,
  FOREIGN KEY (pelanggan_id) REFERENCES pelanggan(id),
  FOREIGN KEY (produk_id)    REFERENCES produk(id)
);

INSERT INTO pesanan (id, pelanggan_id, produk_id, jumlah) VALUES
  (1, 1, 1, 2);

Memicu Error dengan Sengaja #

Tiga error paling produktif untuk dipicu di klien. Yang pertama: memasukkan produk dengan id yang sudah terpakai — pelanggaran PRIMARY key:

sql
-- @error
INSERT INTO produk (id, nama, harga) VALUES (1, 'Kopi Palsu', 99000);

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1062 (23000): Duplicate entry '1' for key 'produk.PRIMARY'
Bye

Perhatikan formatnya: ERROR 1062 (23000) — error code di depan, SQLSTATE di kurung — persis pasangan yang barusan kita bedah. Yang kedua: membuat pesanan untuk pelanggan bernomor 99 yang tidak pernah ada — foreign key menolak anak yatim:

sql
-- @error
INSERT INTO pesanan (id, pelanggan_id, produk_id, jumlah) VALUES (2, 99, 1, 1);

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`belajar_app`.`pesanan`, CONSTRAINT `pesanan_ibfk_1` FOREIGN KEY (`pelanggan_id`) REFERENCES `pelanggan` (`id`))
Bye

Pesan errornya panjang karena informatif: MySQL menyebut tabel anak, nama constraint, dan tabel induk yang gagal dipenuhi — cukup detail untuk mendiagnosis tanpa menebak. Kode 1452 dan 1062 sama-sama ber-SQLSTATE 23000; itulah contoh nyata “kode MySQL detail, SQLSTATE kasar”. Yang ketiga: menyebut database yang tidak ada — error koneksi-jenis yang akan kita jumpai di aplikasi:

sql
-- @error
USE database_tidak_ada;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1049 (42000): Unknown database 'database_tidak_ada'
Bye

Ketiga error di atas sengaja dipicu di sesi klien agar kita melihat wujud aslinya. Di aplikasi, wujud yang sama hadir sebagai exception — dan di situlah penanganan sesungguhnya terjadi.

Menangani dari Aplikasi: tangani.py #

Berikut tangani.py — tersimpan di bundle chapter (bagian Berkas Sumber). Program ini memicu tiga error secara terkendali: INSERT duplikat di belajar_app, koneksi ke database yang tidak ada, dan koneksi ke port yang tidak difasilitasi server:

python
"""tangani.py — menangani error MySQL dari sisi aplikasi dengan PyMySQL.

Jalankan dari folder chapter:  python3 tangani.py
Port default 3306; timpa lewat variabel lingkungan DB_PORT.
"""

import os

import pymysql

port = int(os.environ.get("DB_PORT", "3306"))

print("== 1062: INSERT duplikat ==")
with pymysql.connect(
    host="127.0.0.1",
    user="root",
    password="",
    database="belajar_app",
    port=port,
) as koneksi:
    try:
        with koneksi.cursor() as kursor:
            kursor.execute(
                "INSERT INTO produk (id, nama, harga) VALUES (%s, %s, %s)",
                (1, "Kopi Duplikat", 99000),
            )
        koneksi.commit()
        print("Produk tersimpan.")
    except pymysql.err.IntegrityError as err:
        if err.args[0] == 1062:
            print("Produk dengan id 1 sudah ada; data lama tetap dipakai.")
        else:
            print("Penyimpanan gagal, kode error:", err.args[0])

print("== 1049: database tidak ada ==")
try:
    pymysql.connect(
        host="127.0.0.1",
        user="root",
        password="",
        database="database_tidak_ada",
        port=port,
    )
except pymysql.err.OperationalError as err:
    print("Koneksi ditolak kode 1049: database tidak ditemukan, cek namanya.")

print("== 2003: server tidak merespons ==")
try:
    pymysql.connect(
        host="127.0.0.1",
        user="root",
        password="",
        database="belajar_app",
        port=3307,
        connect_timeout=2,
    )
except pymysql.err.OperationalError as err:
    print("Koneksi gagal kode 2003: server tidak terjangkau di port itu.")

Menjalankannya dari terminal (dengan DB_PORT seperti biasa; di komputer Anda cukup python3 tangani.py):

bash
-- @test-bash
DB_PORT=3306 python3 content/mysql/integrasi-aplikasi/menangani-database-errors/tangani.py

Output yang Dihasilkan:

text
== 1062: INSERT duplikat ==
Produk dengan id 1 sudah ada; data lama tetap dipakai.
== 1049: database tidak ada ==
Koneksi ditolak kode 1049: database tidak ditemukan, cek namanya.
== 2003: server tidak merespons ==
Koneksi gagal kode 2003: server tidak terjangkau di port itu.

Bedah tiga polanya satu per satu:

  1. Blok pertama (1062): INSERT duplikat ditangkap sebagai pymysql.err.IntegrityError, lalu kode di err.args[0] diperiksa sebelum memutuskan reaksi. Kode 1062 mendapat pesan ramah yang sesuai konteks bisnis (“produk sudah ada”) — bukan teks mentah dari server. Cabang else tetap ada: error integritas lain (misalnya 1452) tidak boleh menyamar sebagai pesan “sudah ada”. Perhatikan juga letak koneksi.commit(): hanya tercapai bila INSERT sukses — kegagalan membuat blok try lompat langsung ke except.
  2. Blok kedua (1049): koneksi ke database fiktif ditolak sebelum sesi terbentuk, ditangkap sebagai OperationalError. Dua kata di pesan kunci: cek namanya — pesan yang menyuruh orang memeriksa sesuatu yang bisa diperiksa, jauh lebih berguna daripada nomor kode mentah.
  3. Blok ketiga (2003): koneksi ke port 3307 (tidak ada server di sana) gagal setelah connect_timeout=2 detik — parameter yang memastikan aplikasi tidak menggantung menunggu server yang tidak akan menjawab. err.args[0] bernilai 2003, pola yang sama dengan server benar-benar mati di produksi.

PyMySQL memetakan kode ke kelas exception: IntegrityError untuk pelanggaran constraint (kelas SQLSTATE 23), OperationalError untuk kegagalan operasional seperti koneksi, ProgrammingError untuk SQL salah, DataError untuk nilai tak cocok. Memilih kelas yang tepat di except adalah setengah dari penanganan yang bersih — setengah sisanya adalah reaksi yang sesuai kode.

Deadlock: Error yang Diminta untuk Diulang #

Satu kode di tabel kita menuntut reaksi berbeda dari semuanya. Deadlock (1213) terjadi ketika dua transaksi masing-masing memegang kunci yang dibutuhkan transaksi lain; InnoDB mendeteksi siklus itu, membatalkan salah satu transaksi (si “korban”), dan mengembalikan kontrol ke aplikasi — transaksi pemenang berjalan normal. Data tidak rusak, konsistensi tetap terjaga; satu-satunya tindakan yang benar bagi korban adalah mengulangi seluruh transaksi dari awal, karena rollback sudah dilakukan server.

Pola retry-nya lazim ditulis dengan jeda berlipat (backoff) agar serentakan request tidak mengulang tabrakan yang sama:

python
import time

for percobaan in range(3):
    try:
        with koneksi.cursor() as kursor:
            kursor.execute("UPDATE produk SET stok = stok - %s WHERE id = %s", (1, 1))
        koneksi.commit()
        break
    except pymysql.err.OperationalError as err:
        if err.args[0] == 1213 and percobaan < 2:
            time.sleep(0.05 * 2**percobaan)
            continue
        raise

Perulangan mencoba hingga tiga kali: break keluar saat transaksi berhasil, time.sleep memberi jeda yang membesar (0,05 detik, lalu 0,1) sebelum mencoba lagi, dan raise melempar error yang bukan deadlock — atau deadlock yang bertahan setelah usaha terakhir — ke penangan di atasnya. Dua aturan yang menyertainya: ulangi transaksi lengkap (bukan statement terakhir saja, sebab sebagian perubahan sudah di-rollback), dan pasang batas percobaan — deadlock yang terus terjadi adalah gejala desain transaksi yang terlalu panjang atau urutan akses yang tidak konsisten, bukan masalah yang diselesaikan retry tanpa batas.

Pesan untuk Pengguna, Detail untuk Log #

Aturan terakhir section ini sependek pasalnya dan sebesar konsekuensinya: pesan mentah database tidak boleh sampai ke pengguna. Teks error MySQL memuat nama tabel, nama constraint, dan potongan nilai data — peta gratis bagi penyerang yang sedang memetasi struktur kita (persis materi SQL injection di section keamanan). Pola yang sehat bercabang dua: pengguna menerima kalimat pendek yang bisa ditindaklanjuti (“email ini sudah terdaftar, silakan masuk”), sementara baris lengkapnya — kode, SQLSTATE, query bila perlu — masuk log server bersama konteks: request apa, user mana, timestamp. Log inilah yang kita baca saat reproduksi bug: error yang tertangkap lalu dilupakan tanpa dicatat adalah bug yang akan kembali tanpa jejak.

Membersihkan Latihan #

bash
-- @test-bash
mysql -h 127.0.0.1 -P 3306 -u root -e "DROP DATABASE IF EXISTS belajar_app;"

Referensi #

Dengan itu, tugas paling berat integrasi aplikasi tuntas: koneksi dibuka hemat lewat pooling, query dikirim aman lewat placeholder, skema berubah terkendali lewat migration, data awal hadir lewat seeding, dan kegagalan disambut dengan reaksi yang tepat. Section berikutnya naik kelas dari memakai SQL ke mendalami SQL — Advanced SQL membahas teknik-teknik yang selama ini kita lewati demi kejelasan alur.

Berkas Sumber

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

tangani.py

Unduh berkas
python
"""tangani.py — menangani error MySQL dari sisi aplikasi dengan PyMySQL.

Jalankan dari folder chapter:  python3 tangani.py
Port default 3306; timpa lewat variabel lingkungan DB_PORT.
"""

import os

import pymysql

port = int(os.environ.get("DB_PORT", "3306"))

print("== 1062: INSERT duplikat ==")
with pymysql.connect(
    host="127.0.0.1",
    user="root",
    password="",
    database="belajar_app",
    port=port,
) as koneksi:
    try:
        with koneksi.cursor() as kursor:
            kursor.execute(
                "INSERT INTO produk (id, nama, harga) VALUES (%s, %s, %s)",
                (1, "Kopi Duplikat", 99000),
            )
        koneksi.commit()
        print("Produk tersimpan.")
    except pymysql.err.IntegrityError as err:
        if err.args[0] == 1062:
            print("Produk dengan id 1 sudah ada; data lama tetap dipakai.")
        else:
            print("Penyimpanan gagal, kode error:", err.args[0])

print("== 1049: database tidak ada ==")
try:
    pymysql.connect(
        host="127.0.0.1",
        user="root",
        password="",
        database="database_tidak_ada",
        port=port,
    )
except pymysql.err.OperationalError as err:
    print("Koneksi ditolak kode 1049: database tidak ditemukan, cek namanya.")

print("== 2003: server tidak merespons ==")
try:
    pymysql.connect(
        host="127.0.0.1",
        user="root",
        password="",
        database="belajar_app",
        port=3307,
        connect_timeout=2,
    )
except pymysql.err.OperationalError as err:
    print("Koneksi gagal kode 2003: server tidak terjangkau di port itu.")