Build CoreOrdered learning track

Error Handling: Exceptions, SQLSTATE, and Domain Failures

Learn PL/pgSQL In Action - Part 011

Error handling production-grade di PL/pgSQL: exception boundary, SQLSTATE, RAISE, domain failure, retryability, dan pola error contract yang defensible.

17 min read3205 words
PrevNext
Lesson 1140 lesson track0922 Build Core
#postgresql#plpgsql#database#error-handling+3 more

Part 011 — Error Handling: Exceptions, SQLSTATE, and Domain Failures

Target part ini: kamu tidak hanya bisa menulis EXCEPTION WHEN others THEN ..., tetapi bisa mendesain failure contract untuk database-side code: error mana yang harus meledak, error mana yang harus diterjemahkan, error mana yang retryable, error mana yang domain violation, dan error mana yang merupakan bug internal.

PL/pgSQL bukan Java try/catch yang kebetulan berada di database. Ia berjalan di dalam transaksi PostgreSQL. Artinya setiap error bukan hanya control-flow event, tapi juga bisa mengubah nasib seluruh transaksi, lock, savepoint internal, dan observability downstream.

Materi ini sengaja praktis. Kita akan berpikir seperti engineer yang harus menjaga sistem produksi: bagaimana error terlihat di log, bagaimana caller membedakannya, bagaimana auditor membaca failure, dan bagaimana operasi bisa mengambil tindakan tanpa menebak-nebak.


1. Mental Model: Error Adalah Bagian dari API

Dalam PL/pgSQL, function/procedure tidak hanya punya return value. Ia juga punya error surface.

Error surface mencakup:

  1. SQLSTATE yang keluar.
  2. message utama.
  3. detail.
  4. hint.
  5. object terkait, misalnya table/column/constraint.
  6. apakah transaksi caller tetap bisa lanjut atau harus rollback.
  7. apakah caller boleh retry.
  8. apakah error tersebut domain-level, infrastructure-level, data-level, atau programming bug.

Kalau error surface tidak dirancang, caller akan membaca error message sebagai string. Itu rapuh.

Contoh buruk:

RAISE EXCEPTION 'invalid case';

Masalahnya:

  • invalid karena state salah?
  • invalid karena assignee kosong?
  • invalid karena case sudah closed?
  • invalid karena caller tidak punya privilege?
  • invalid karena data corrupt?
  • caller harus retry atau tidak?

Contoh lebih baik:

RAISE EXCEPTION USING
    ERRCODE = 'P2401',
    MESSAGE = 'case transition rejected',
    DETAIL  = format(
        'case_id=%s, current_status=%s, requested_status=%s',
        p_case_id,
        v_current_status,
        p_requested_status
    ),
    HINT    = 'Check the allowed transition matrix before calling transition_case_status.';

Di sini error bukan lagi teriakan acak. Ia menjadi contract.


2. Jangan Mulai dari EXCEPTION; Mulai dari Taxonomy

Production error handling dimulai dari klasifikasi.

KategoriMaknaContohBiasanya Ditangani Dengan
Domain failureRequest valid secara teknis tapi melanggar aturan bisnistransition case dari CLOSED ke IN_REVIEWcustom SQLSTATE, clear message, no retry
Data integrity failureConstraint database menolak perubahanunique violation, foreign key violationbiarkan constraint bicara atau translate selektif
Concurrency failureDua transaksi bersaingserialization failure, deadlock, lock timeoutretry di caller/orchestrator
Not found / stale dataEntity hilang atau versi tidak cocokupdate zero rowscustom not-found/stale error
Authorization boundaryCaller tidak boleh melakukan aksiprivilege/domain role gagalcustom domain auth error atau PostgreSQL privilege error
Infrastructure/operationalDisk, connection, timeout, unavailable resourcestatement timeout, IO errorjangan ditelan; escalate
Programming bugBug function itu sendiriimpossible branch, null unexpectedassert/exception; fail fast

Diagram keputusan:

Aturan praktis:

  • Jangan menangkap error hanya agar function “tidak gagal”.
  • Jangan menyamakan domain rejection dengan infrastructure failure.
  • Jangan retry domain failure.
  • Jangan translate semua error menjadi satu message generik.
  • Jangan pakai WHEN others kecuali kamu tahu persis apa yang dipertahankan, dicatat, dan dilempar ulang.

3. PostgreSQL Error Primitive yang Wajib Dikuasai

PL/pgSQL menyediakan RAISE untuk menghasilkan message atau exception.

Bentuk umum:

RAISE [ level ] 'format' [, expression [, ... ]];

Atau bentuk production-grade:

RAISE EXCEPTION USING
    ERRCODE = 'P2401',
    MESSAGE = 'case transition rejected',
    DETAIL  = 'case_id=123, current_status=CLOSED, requested_status=IN_REVIEW',
    HINT    = 'Only reopen through reopen_case procedure.';

Level penting:

LevelEfek UmumPemakaian
DEBUGdiagnostic rendahlocal troubleshooting, dev session
LOGmasuk server log sesuai configoperational signal, jarang dari business code
INFOinformasi ke clientadministrative script, migration output
NOTICEdefault-visible noticeprogress message non-fatal
WARNINGwarning non-fataldegraded behavior, suspicious state
EXCEPTIONerror fatal untuk statement/blockdomain rejection, invariant breach

Default RAISE tanpa level adalah EXCEPTION.

Contoh:

RAISE NOTICE 'processed % cases', v_processed_count;
RAISE EXCEPTION 'case % not found', p_case_id;

Tapi untuk sistem besar, lebih baik gunakan USING agar error dapat diproses mesin dan manusia.


4. SQLSTATE: Jangan Biarkan Caller Menebak String

SQLSTATE adalah kode lima karakter yang mengklasifikasi error SQL. PostgreSQL memakai banyak kode standar dan kode spesifik PostgreSQL.

Contoh umum:

SQLSTATEConditionMakna
02000no_data_foundtidak ada row untuk operasi STRICT
21000cardinality_violationhasil lebih dari satu saat hanya satu diharapkan
23505unique_violationunique constraint gagal
23503foreign_key_violationforeign key gagal
23514check_violationcheck constraint gagal
40001serialization_failuretransaksi serializable perlu retry
40P01deadlock_detecteddeadlock terjadi
55P03lock_not_availablelock tidak tersedia
57014query_canceledquery dibatalkan, sering karena timeout/cancel
P0001raise_exceptiondefault custom exception PL/pgSQL
P0002no_data_foundPL/pgSQL no data found
P0003too_many_rowsPL/pgSQL too many rows
P0004assert_failureassertion failure

Untuk custom application/domain error, gunakan kode custom yang tidak bentrok dengan kode standar. Banyak tim memakai kelas P untuk PL/pgSQL/application exception, misalnya:

Range TimMakna
P1xxxvalidation/domain input
P2xxxworkflow/state machine
P3xxxauthorization/policy
P4xxxidempotency/replay
P5xxxoperational guardrail

Contoh registry:

SQLSTATENama InternalMakna
P2001case_not_foundcase id tidak ditemukan
P2401case_transition_rejectedtransisi state tidak valid
P2402case_transition_staleoptimistic version mismatch
P3001case_policy_deniedactor tidak boleh melakukan aksi
P4001idempotency_conflictkey sama tapi payload berbeda

Jangan membuat kode custom tanpa registry. Tanpa registry, SQLSTATE berubah menjadi magic number baru.


5. Pattern: Domain Error Helper Function

Kalau banyak function perlu menaikkan error domain secara konsisten, buat helper. Namun helper harus tetap sederhana.

CREATE OR REPLACE FUNCTION app.raise_domain_error(
    p_sqlstate text,
    p_message  text,
    p_detail   text DEFAULT NULL,
    p_hint     text DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
    IF p_sqlstate IS NULL OR length(p_sqlstate) <> 5 THEN
        RAISE EXCEPTION USING
            ERRCODE = 'P0001',
            MESSAGE = 'invalid domain SQLSTATE',
            DETAIL  = format('sqlstate=%s', p_sqlstate);
    END IF;

    RAISE EXCEPTION USING
        ERRCODE = p_sqlstate,
        MESSAGE = p_message,
        DETAIL  = p_detail,
        HINT    = p_hint;
END;
$$;

Pemakaian:

PERFORM app.raise_domain_error(
    'P2401',
    'case transition rejected',
    format(
        'case_id=%s, current_status=%s, requested_status=%s',
        p_case_id,
        v_case.status,
        p_next_status
    ),
    'Use an allowed transition from case_status_transition_rule.'
);

Trade-off:

  • Pro: konsisten.
  • Pro: mudah standardisasi message, detail, hint.
  • Kontra: stack trace akan menunjuk helper juga.
  • Kontra: helper bisa menyembunyikan lokasi asli bila dipakai terlalu agresif.

Untuk domain platform besar, helper wajar. Untuk function kecil, RAISE EXCEPTION USING langsung sering lebih jelas.


6. EXCEPTION Block Bukan Gratis

PL/pgSQL EXCEPTION block menciptakan subtransaction-like boundary. Secara operasional, ini bukan control-flow murah yang boleh dipasang di setiap cabang.

Gunakan EXCEPTION untuk:

  • menerjemahkan error yang benar-benar kamu pahami;
  • melakukan cleanup lokal yang aman;
  • retry mikro untuk operasi kecil dan bounded;
  • mengubah low-level constraint menjadi domain-specific error;
  • mencatat diagnostic lalu rethrow.

Jangan gunakan EXCEPTION untuk:

  • mengganti IF EXISTS;
  • mengabaikan error;
  • menyembunyikan data corruption;
  • membuat “best effort” mutation tanpa kontrak;
  • menangkap semua error agar batch terlihat sukses.

Buruk:

BEGIN
    INSERT INTO case_assignment(case_id, user_id)
    VALUES (p_case_id, p_user_id);
EXCEPTION WHEN others THEN
    -- silently ignore
END;

Ini sangat berbahaya. Caller mengira assignment berhasil, padahal tidak.

Lebih baik:

BEGIN
    INSERT INTO case_assignment(case_id, user_id)
    VALUES (p_case_id, p_user_id);
EXCEPTION
    WHEN unique_violation THEN
        RAISE EXCEPTION USING
            ERRCODE = 'P2101',
            MESSAGE = 'case assignment already exists',
            DETAIL  = format('case_id=%s, user_id=%s', p_case_id, p_user_id),
            HINT    = 'Use reassign_case when replacing an existing assignee.';
END;

Lebih baik lagi jika idempotent behavior memang diinginkan:

INSERT INTO case_assignment(case_id, user_id)
VALUES (p_case_id, p_user_id)
ON CONFLICT (case_id, user_id) DO NOTHING;

GET DIAGNOSTICS v_rows = ROW_COUNT;

IF v_rows = 0 THEN
    RAISE NOTICE 'assignment already existed: case_id=%, user_id=%', p_case_id, p_user_id;
END IF;

Tidak semua konflik perlu exception. Kadang SQL sendiri sudah menyediakan operator semantik yang lebih tepat.


7. Pattern: Translate Constraint Error Secara Sempit

Constraint adalah guardrail terbaik untuk invariant data. Tetapi constraint error mentah kadang terlalu low-level untuk caller.

Contoh table:

CREATE TABLE enforcement_case (
    id uuid PRIMARY KEY,
    status text NOT NULL,
    severity text NOT NULL,
    version bigint NOT NULL DEFAULT 1,
    CONSTRAINT enforcement_case_status_chk
        CHECK (status IN ('DRAFT', 'OPEN', 'IN_REVIEW', 'ESCALATED', 'CLOSED')),
    CONSTRAINT enforcement_case_severity_chk
        CHECK (severity IN ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL'))
);

Function:

CREATE OR REPLACE FUNCTION app.create_case(
    p_case_id uuid,
    p_status text,
    p_severity text
)
RETURNS uuid
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO enforcement_case(id, status, severity)
    VALUES (p_case_id, p_status, p_severity);

    RETURN p_case_id;
EXCEPTION
    WHEN check_violation THEN
        RAISE EXCEPTION USING
            ERRCODE = 'P1001',
            MESSAGE = 'case input violates allowed value policy',
            DETAIL  = format('status=%s, severity=%s', p_status, p_severity),
            HINT    = 'Use allowed status and severity values from the case contract.';
END;
$$;

Masalah: check_violation bisa berasal dari constraint mana pun di statement itu.

Lebih aman menggunakan diagnostic constraint name.

CREATE OR REPLACE FUNCTION app.create_case(
    p_case_id uuid,
    p_status text,
    p_severity text
)
RETURNS uuid
LANGUAGE plpgsql
AS $$
DECLARE
    v_constraint text;
BEGIN
    INSERT INTO enforcement_case(id, status, severity)
    VALUES (p_case_id, p_status, p_severity);

    RETURN p_case_id;
EXCEPTION
    WHEN check_violation THEN
        GET STACKED DIAGNOSTICS
            v_constraint = CONSTRAINT_NAME;

        IF v_constraint = 'enforcement_case_status_chk' THEN
            RAISE EXCEPTION USING
                ERRCODE = 'P1002',
                MESSAGE = 'invalid case status',
                DETAIL  = format('status=%s', p_status),
                HINT    = 'Allowed statuses: DRAFT, OPEN, IN_REVIEW, ESCALATED, CLOSED.';
        ELSIF v_constraint = 'enforcement_case_severity_chk' THEN
            RAISE EXCEPTION USING
                ERRCODE = 'P1003',
                MESSAGE = 'invalid case severity',
                DETAIL  = format('severity=%s', p_severity),
                HINT    = 'Allowed severities: LOW, MEDIUM, HIGH, CRITICAL.';
        ELSE
            RAISE;
        END IF;
END;
$$;

Prinsip:

  • Tangkap kategori error.
  • Baca diagnostic.
  • Translate hanya constraint yang kamu kenali.
  • RAISE; untuk yang tidak dikenal.

8. GET STACKED DIAGNOSTICS: Cara Membaca Error yang Ditangkap

Di dalam EXCEPTION handler, kamu bisa mengambil informasi error yang sedang ditangani.

Template:

DECLARE
    v_state      text;
    v_message    text;
    v_detail     text;
    v_hint       text;
    v_context    text;
    v_constraint text;
    v_table      text;
    v_column     text;
BEGIN
    -- risky operation
EXCEPTION WHEN others THEN
    GET STACKED DIAGNOSTICS
        v_state      = RETURNED_SQLSTATE,
        v_message    = MESSAGE_TEXT,
        v_detail     = PG_EXCEPTION_DETAIL,
        v_hint       = PG_EXCEPTION_HINT,
        v_context    = PG_EXCEPTION_CONTEXT,
        v_constraint = CONSTRAINT_NAME,
        v_table      = TABLE_NAME,
        v_column     = COLUMN_NAME;

    RAISE;
END;

Gunakan ini untuk:

  • membedakan constraint tertentu;
  • mencatat diagnostic yang cukup;
  • membuat audit failure;
  • memperbaiki message domain;
  • rethrow original exception.

Jangan gunakan ini untuk:

  • mengubah semua error menjadi sukses;
  • menyimpan sensitive data mentah ke log;
  • mengambil keputusan bisnis dari string message.

9. RAISE; vs RAISE EXCEPTION ...

Di dalam exception handler:

RAISE;

akan melempar ulang exception asli.

Gunakan RAISE; ketika:

  • kamu hanya ingin logging/diagnostic tambahan;
  • error bukan milik boundary yang sedang kamu translate;
  • kamu ingin mempertahankan SQLSTATE asli;
  • caller butuh retryability asli seperti 40001 atau 40P01.

Contoh:

BEGIN
    PERFORM app.perform_case_batch(p_batch_id);
EXCEPTION WHEN others THEN
    GET STACKED DIAGNOSTICS
        v_state   = RETURNED_SQLSTATE,
        v_message = MESSAGE_TEXT;

    INSERT INTO batch_failure_log(batch_id, sqlstate, message, occurred_at)
    VALUES (p_batch_id, v_state, v_message, clock_timestamp());

    RAISE;
END;

Kalau kamu memakai:

RAISE EXCEPTION 'batch failed';

kamu menghancurkan informasi penting seperti SQLSTATE asli.

Lebih aman kalau harus membungkus:

RAISE EXCEPTION USING
    ERRCODE = v_state,
    MESSAGE = 'batch failed',
    DETAIL  = format('batch_id=%s, original_message=%s', p_batch_id, v_message);

Tetapi mempertahankan SQLSTATE asli sambil mengubah message juga harus hati-hati. Bisa membuat caller mengira error adalah error native padahal sudah dibungkus. Untuk domain boundary, custom SQLSTATE lebih baik.


10. Avoid WHEN others Tanpa Rethrow

WHEN others adalah pisau tajam.

Anti-pattern:

EXCEPTION WHEN others THEN
    RETURN false;

Kenapa buruk?

  • unique violation terlihat sama dengan disk failure;
  • timeout terlihat sama dengan invalid input;
  • caller kehilangan SQLSTATE;
  • operasi parsial bisa tersembunyi;
  • sistem monitoring tidak melihat error;
  • audit bisa berbohong.

Pattern yang masih masuk akal:

EXCEPTION WHEN others THEN
    GET STACKED DIAGNOSTICS
        v_state   = RETURNED_SQLSTATE,
        v_message = MESSAGE_TEXT,
        v_context = PG_EXCEPTION_CONTEXT;

    INSERT INTO app_error_log(
        operation_name,
        correlation_id,
        sqlstate,
        message,
        context,
        occurred_at
    ) VALUES (
        'close_case',
        p_correlation_id,
        v_state,
        v_message,
        v_context,
        clock_timestamp()
    );

    RAISE;

Kalau kamu benar-benar perlu mengubah error menjadi return value, batasi dengan SQLSTATE spesifik:

EXCEPTION
    WHEN unique_violation THEN
        RETURN false;

Bahkan ini pun harus jelas dalam function contract.


11. STRICT, no_data_found, dan too_many_rows

SELECT ... INTO STRICT adalah alat bagus untuk menyatakan “saya mengharapkan tepat satu row”.

SELECT *
INTO STRICT v_case
FROM enforcement_case
WHERE id = p_case_id;

Kalau row tidak ada, PL/pgSQL menaikkan NO_DATA_FOUND. Kalau lebih dari satu, TOO_MANY_ROWS.

Namun ada trade-off.

Bagus untuk invariant internal:

SELECT *
INTO STRICT v_policy
FROM case_policy
WHERE policy_code = p_policy_code;

Artinya policy code harus ada dan unik. Kalau tidak, itu bug konfigurasi.

Kurang bagus untuk domain not-found yang harus punya message API jelas:

SELECT *
INTO v_case
FROM enforcement_case
WHERE id = p_case_id;

IF NOT FOUND THEN
    RAISE EXCEPTION USING
        ERRCODE = 'P2001',
        MESSAGE = 'case not found',
        DETAIL  = format('case_id=%s', p_case_id);
END IF;

Rule:

  • Pakai STRICT untuk invariant internal “tepat satu harus ada”.
  • Pakai non-STRICT + explicit IF NOT FOUND untuk domain not-found yang harus komunikatif.
  • Jangan biarkan NO_DATA_FOUND mentah keluar dari public-facing function kecuali itu memang contract.

12. Pattern: Mutation with Proof

Mutation yang baik tidak hanya menjalankan UPDATE. Ia membuktikan outcome.

Buruk:

UPDATE enforcement_case
SET status = p_next_status
WHERE id = p_case_id;

Tidak jelas:

  • row ada atau tidak?
  • row berubah atau tidak?
  • status lama apa?
  • caller stale atau tidak?

Lebih baik:

UPDATE enforcement_case c
SET status = p_next_status,
    version = c.version + 1,
    updated_at = clock_timestamp()
WHERE c.id = p_case_id
  AND c.version = p_expected_version
RETURNING c.id, c.status, c.version
INTO v_updated_case;

IF NOT FOUND THEN
    SELECT c.version
    INTO v_actual_version
    FROM enforcement_case c
    WHERE c.id = p_case_id;

    IF NOT FOUND THEN
        RAISE EXCEPTION USING
            ERRCODE = 'P2001',
            MESSAGE = 'case not found',
            DETAIL  = format('case_id=%s', p_case_id);
    END IF;

    RAISE EXCEPTION USING
        ERRCODE = 'P2402',
        MESSAGE = 'case update rejected because version is stale',
        DETAIL  = format(
            'case_id=%s, expected_version=%s, actual_version=%s',
            p_case_id,
            p_expected_version,
            v_actual_version
        ),
        HINT = 'Reload the case and retry with the current version.';
END IF;

Ini lebih verbose, tetapi production-grade. Ia membedakan not-found dari stale-write.


13. Domain Failures dalam State Machine

State machine adalah salah satu tempat terbaik untuk PL/pgSQL, tetapi hanya jika failure-nya jelas.

Contoh rule table:

CREATE TABLE case_status_transition_rule (
    from_status text NOT NULL,
    to_status   text NOT NULL,
    actor_role  text NOT NULL,
    requires_reason boolean NOT NULL DEFAULT false,
    PRIMARY KEY (from_status, to_status, actor_role)
);

Function:

CREATE OR REPLACE FUNCTION app.transition_case_status(
    p_case_id uuid,
    p_actor_id uuid,
    p_actor_role text,
    p_next_status text,
    p_reason text,
    p_expected_version bigint
)
RETURNS TABLE (
    case_id uuid,
    previous_status text,
    new_status text,
    new_version bigint
)
LANGUAGE plpgsql
AS $$
DECLARE
    v_case enforcement_case%ROWTYPE;
    v_rule case_status_transition_rule%ROWTYPE;
BEGIN
    SELECT *
    INTO v_case
    FROM enforcement_case c
    WHERE c.id = p_case_id
    FOR UPDATE;

    IF NOT FOUND THEN
        RAISE EXCEPTION USING
            ERRCODE = 'P2001',
            MESSAGE = 'case not found',
            DETAIL  = format('case_id=%s', p_case_id);
    END IF;

    IF v_case.version <> p_expected_version THEN
        RAISE EXCEPTION USING
            ERRCODE = 'P2402',
            MESSAGE = 'case transition rejected because version is stale',
            DETAIL  = format(
                'case_id=%s, expected_version=%s, actual_version=%s',
                p_case_id,
                p_expected_version,
                v_case.version
            );
    END IF;

    SELECT *
    INTO v_rule
    FROM case_status_transition_rule r
    WHERE r.from_status = v_case.status
      AND r.to_status = p_next_status
      AND r.actor_role = p_actor_role;

    IF NOT FOUND THEN
        RAISE EXCEPTION USING
            ERRCODE = 'P2401',
            MESSAGE = 'case transition rejected',
            DETAIL  = format(
                'case_id=%s, from_status=%s, to_status=%s, actor_role=%s',
                p_case_id,
                v_case.status,
                p_next_status,
                p_actor_role
            ),
            HINT = 'Check case_status_transition_rule for allowed transitions.';
    END IF;

    IF v_rule.requires_reason AND nullif(trim(p_reason), '') IS NULL THEN
        RAISE EXCEPTION USING
            ERRCODE = 'P1004',
            MESSAGE = 'case transition reason is required',
            DETAIL  = format(
                'case_id=%s, from_status=%s, to_status=%s',
                p_case_id,
                v_case.status,
                p_next_status
            );
    END IF;

    UPDATE enforcement_case c
    SET status = p_next_status,
        version = c.version + 1,
        updated_at = clock_timestamp()
    WHERE c.id = p_case_id
    RETURNING c.id, v_case.status, c.status, c.version
    INTO case_id, previous_status, new_status, new_version;

    RETURN NEXT;
END;
$$;

Failure contract:

ErrorSQLSTATERetry?Caller Action
Case not foundP2001noshow not-found / abort workflow
Stale versionP2402yes after reloadreload and retry
Transition rejectedP2401nofix requested transition/role
Reason requiredP1004noask user for reason

Ini jauh lebih defensible daripada satu error invalid transition.


14. Retryability: Jangan Retry Semua Error

Retry adalah keputusan arsitektural, bukan refleks.

Error ClassRetry?Catatan
40001 serialization failureYaretry seluruh transaksi dari caller
40P01 deadlock detectedYaretry dengan backoff
55P03 lock not availableTergantungretry bisa, tapi perlu limit/backoff
57014 query canceledTergantungbisa timeout; jangan infinite retry
23505 unique violationBiasanya tidakkecuali idempotency/insert race tertentu
custom P1xxx validationTidakinput harus diperbaiki
custom P24xx state failureTergantungstale version bisa retry setelah reload; invalid transition tidak
internal bug/assertTidakfix code/data

Dalam banyak sistem, retry harus dilakukan di application service atau job orchestrator, bukan di dalam PL/pgSQL, karena retry perlu mengulang seluruh transaction boundary.

PL/pgSQL boleh melakukan retry mikro hanya jika:

  • operasi sangat kecil;
  • bounded loop;
  • side effect aman;
  • tidak memanggil external system;
  • tidak menyembunyikan kegagalan akhir.

Contoh retry lock kecil:

CREATE OR REPLACE FUNCTION app.try_allocate_case_number()
RETURNS bigint
LANGUAGE plpgsql
AS $$
DECLARE
    v_attempt int := 0;
    v_case_number bigint;
BEGIN
    LOOP
        v_attempt := v_attempt + 1;

        BEGIN
            UPDATE case_number_sequence
            SET last_value = last_value + 1
            WHERE sequence_name = 'case'
            RETURNING last_value INTO v_case_number;

            RETURN v_case_number;
        EXCEPTION
            WHEN deadlock_detected OR serialization_failure THEN
                IF v_attempt >= 3 THEN
                    RAISE;
                END IF;
                PERFORM pg_sleep(0.05 * v_attempt);
        END;
    END LOOP;
END;
$$;

Ini masih harus dipakai hati-hati. Untuk transaction besar, retry di caller lebih benar.


15. Error Handling dan Transaction Semantics

Ingat: ketika statement gagal, transaction bisa berada dalam failed state kecuali error ditangkap di PL/pgSQL exception block. PL/pgSQL exception block memungkinkan block lokal menangani error dan melanjutkan transaksi, tetapi perubahan di dalam block yang gagal akan di-roll back ke boundary internal tersebut.

Contoh:

CREATE OR REPLACE FUNCTION app.demo_exception_boundary()
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO audit_log(message) VALUES ('before risky operation');

    BEGIN
        INSERT INTO unique_table(id) VALUES (1);
        INSERT INTO unique_table(id) VALUES (1); -- fails
    EXCEPTION WHEN unique_violation THEN
        RAISE NOTICE 'duplicate ignored inside inner block';
    END;

    INSERT INTO audit_log(message) VALUES ('after risky operation');
END;
$$;

Efek penting:

  • insert audit pertama tetap ada;
  • insert dalam inner block yang gagal di-rollback;
  • function bisa lanjut setelah handler;
  • ini bukan alasan untuk menelan error sembarangan.

Gunakan boundary kecil. Jangan bungkus seluruh function besar dalam satu EXCEPTION WHEN others kalau hanya satu statement yang expected bisa gagal.


16. Pattern: Idempotency Conflict sebagai Domain Error

Idempotency sering salah diperlakukan sebagai unique violation biasa.

Schema:

CREATE TABLE idempotency_request (
    key text PRIMARY KEY,
    request_hash text NOT NULL,
    response_payload jsonb,
    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

Function fragment:

INSERT INTO idempotency_request(key, request_hash)
VALUES (p_idempotency_key, p_request_hash)
ON CONFLICT (key) DO NOTHING;

GET DIAGNOSTICS v_rows = ROW_COUNT;

IF v_rows = 0 THEN
    SELECT r.request_hash, r.response_payload
    INTO v_existing_hash, v_existing_response
    FROM idempotency_request r
    WHERE r.key = p_idempotency_key;

    IF v_existing_hash <> p_request_hash THEN
        RAISE EXCEPTION USING
            ERRCODE = 'P4001',
            MESSAGE = 'idempotency key reused with different request payload',
            DETAIL  = format('idempotency_key=%s', p_idempotency_key),
            HINT    = 'Reuse an idempotency key only for the exact same logical request.';
    END IF;

    -- same request replay: return cached response
END IF;

Ini bukan 23505 untuk caller. Ini P4001 domain protocol violation.


17. Pattern: Error Registry Table

Untuk platform besar, simpan registry error di schema aplikasi.

CREATE TABLE app_error_code (
    sqlstate text PRIMARY KEY,
    name text NOT NULL UNIQUE,
    category text NOT NULL,
    retryable boolean NOT NULL,
    public_message text NOT NULL,
    owner_team text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
    CONSTRAINT app_error_code_sqlstate_chk
        CHECK (sqlstate ~ '^[A-Z0-9]{5}$')
);

Seed:

INSERT INTO app_error_code(sqlstate, name, category, retryable, public_message, owner_team)
VALUES
('P2001', 'case_not_found', 'domain_not_found', false, 'Case was not found.', 'case-platform'),
('P2401', 'case_transition_rejected', 'workflow', false, 'Case transition is not allowed.', 'case-platform'),
('P2402', 'case_transition_stale', 'workflow', true, 'Case was modified by another transaction.', 'case-platform'),
('P4001', 'idempotency_conflict', 'protocol', false, 'Idempotency key conflict.', 'platform-runtime');

Manfaat:

  • API gateway bisa map SQLSTATE ke response code.
  • Observability bisa aggregate by code.
  • Documentation tidak tertinggal dari implementasi.
  • Reviewer bisa menolak custom SQLSTATE liar.

18. Mapping SQLSTATE ke API/Error Response

PL/pgSQL sering dipanggil oleh service layer. Service layer harus memetakan SQLSTATE dengan stabil.

Contoh mapping:

SQLSTATEHTTPgRPCMeaning
P2001404NOT_FOUNDCase tidak ditemukan
P1001400INVALID_ARGUMENTInput invalid
P2401409FAILED_PRECONDITIONTransisi tidak valid
P2402409ABORTEDStale version
P3001403PERMISSION_DENIEDPolicy denied
P4001409ALREADY_EXISTS/FAILED_PRECONDITIONIdempotency conflict
40001503/409ABORTEDRetryable serialization
40P01503ABORTEDRetryable deadlock
57014504/499DEADLINE_EXCEEDED/CANCELLEDTimeout/cancel

Jangan map semua database errors ke HTTP 500. Itu membuang informasi.


19. Error Message Hygiene

Error message production harus cukup detail untuk operasi, tetapi tidak membocorkan data sensitif.

Hindari:

RAISE EXCEPTION 'user % with token % cannot access account %',
    p_user_id,
    p_access_token,
    p_account_id;

Lebih aman:

RAISE EXCEPTION USING
    ERRCODE = 'P3001',
    MESSAGE = 'case access denied',
    DETAIL  = format('actor_id=%s, case_id=%s', p_actor_id, p_case_id),
    HINT    = 'Verify case visibility policy for the actor role.';

Jangan log:

  • password;
  • token;
  • raw authorization header;
  • PII yang tidak diperlukan;
  • dokumen rahasia;
  • full JSON payload jika bisa besar/sensitif.

Gunakan identifiers, hash, correlation id, dan compact context.


20. Failure Audit: Kapan Mencatat Error ke Table?

Tidak semua error harus disimpan ke table. PostgreSQL sudah punya log. Namun untuk workflow defensible, kadang failure adalah business evidence.

Cocok dicatat ke table:

  • transition rejected yang harus diaudit;
  • policy denied untuk case sensitif;
  • batch item failure;
  • validation failure pada ingestion;
  • idempotency conflict;
  • escalation failure.

Tidak cocok dicatat ke table oleh function:

  • semua exception global;
  • high-volume transient error;
  • error yang akan rollback bersama transaksi sehingga log table ikut rollback;
  • sensitive payload.

Kalau mencatat failure di transaksi yang sama, ingat: jika kamu rethrow dan transaksi luar rollback, log table ikut hilang. Itu bisa diinginkan atau tidak.

Untuk failure audit yang harus bertahan walau transaksi gagal, PostgreSQL function biasa bukan tempat ideal karena tidak punya autonomous transaction seperti Oracle. Alternatif:

  • biarkan server log menjadi source;
  • gunakan caller untuk mencatat setelah rollback;
  • gunakan procedure dengan transaction control pada boundary yang sah;
  • gunakan queue/outbox yang ditulis sebelum failure final jika semantik memungkinkan;
  • gunakan external observability dari application layer.

21. Pattern: Batch Item Failure Tanpa Membunuh Seluruh Batch

Kadang satu item gagal tidak boleh menghentikan batch. Ini valid, tetapi kontraknya harus eksplisit.

CREATE TABLE batch_item_result (
    batch_id uuid NOT NULL,
    item_id uuid NOT NULL,
    status text NOT NULL,
    sqlstate text,
    error_message text,
    processed_at timestamptz NOT NULL DEFAULT clock_timestamp(),
    PRIMARY KEY (batch_id, item_id)
);

Function:

CREATE OR REPLACE FUNCTION app.process_case_batch_item(
    p_batch_id uuid,
    p_case_id uuid
)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
    v_state text;
    v_message text;
BEGIN
    BEGIN
        PERFORM app.recalculate_case_risk(p_case_id);

        INSERT INTO batch_item_result(batch_id, item_id, status)
        VALUES (p_batch_id, p_case_id, 'SUCCESS')
        ON CONFLICT (batch_id, item_id) DO UPDATE
        SET status = excluded.status,
            sqlstate = NULL,
            error_message = NULL,
            processed_at = clock_timestamp();
    EXCEPTION WHEN others THEN
        GET STACKED DIAGNOSTICS
            v_state = RETURNED_SQLSTATE,
            v_message = MESSAGE_TEXT;

        INSERT INTO batch_item_result(
            batch_id,
            item_id,
            status,
            sqlstate,
            error_message
        ) VALUES (
            p_batch_id,
            p_case_id,
            'FAILED',
            v_state,
            left(v_message, 500)
        )
        ON CONFLICT (batch_id, item_id) DO UPDATE
        SET status = excluded.status,
            sqlstate = excluded.sqlstate,
            error_message = excluded.error_message,
            processed_at = clock_timestamp();
    END;
END;
$$;

Catatan:

  • Inner block failure rollback hanya operasi dalam inner block sebelum handler.
  • Result ditulis setelah error ditangkap.
  • Function ini sengaja tidak rethrow karena contract-nya adalah item-level failure capture.
  • Caller batch harus menghitung jumlah failed item dan memutuskan status batch.

Jangan pakai pola ini untuk operation yang harus atomic all-or-nothing.


22. Domain Exception vs Constraint: Mana yang Menang?

Pertanyaan desain umum: apakah validasi domain ditaruh sebelum INSERT/UPDATE, atau biarkan constraint gagal?

Jawaban praktis:

  • Constraint tetap harus ada untuk invariant data.
  • Pre-validation boleh ada untuk message yang lebih baik, UX, dan branching domain.
  • Jangan hanya mengandalkan PL/pgSQL validation tanpa constraint untuk invariant kritis.

Contoh:

IF p_due_at <= clock_timestamp() THEN
    RAISE EXCEPTION USING
        ERRCODE = 'P1005',
        MESSAGE = 'case due date must be in the future',
        DETAIL  = format('due_at=%s', p_due_at);
END IF;

Tetapi jika invariant benar-benar harus dijaga, tambahkan constraint bila memungkinkan:

ALTER TABLE case_task
ADD CONSTRAINT case_task_due_at_chk
CHECK (due_at IS NULL OR due_at > created_at);

PL/pgSQL memberi error bagus. Constraint memberi keselamatan permanen.


23. Failure Mode: Handler Mengira FOUND Masih Miliknya

FOUND berubah oleh statement PL/pgSQL tertentu. Jangan mengandalkan FOUND setelah statement lain.

Buruk:

UPDATE enforcement_case
SET status = p_status
WHERE id = p_case_id;

INSERT INTO audit_log(message) VALUES ('updated case');

IF NOT FOUND THEN
    RAISE EXCEPTION 'case not found';
END IF;

FOUND mungkin sudah berubah oleh statement lain. Lebih aman:

UPDATE enforcement_case
SET status = p_status
WHERE id = p_case_id;

GET DIAGNOSTICS v_rows = ROW_COUNT;

IF v_rows = 0 THEN
    RAISE EXCEPTION USING
        ERRCODE = 'P2001',
        MESSAGE = 'case not found',
        DETAIL = format('case_id=%s', p_case_id);
END IF;

INSERT INTO audit_log(message) VALUES ('updated case');

Untuk error handling, jangan mencampur outcome proof dengan statement sampingan.


24. Failure Mode: Exception Handler Terlalu Lebar

Buruk:

BEGIN
    -- 200 lines of logic
EXCEPTION WHEN unique_violation THEN
    RAISE EXCEPTION 'duplicate';
END;

Masalah:

  • unique violation bisa berasal dari banyak table;
  • kamu tidak tahu statement mana gagal;
  • diagnostic jadi ambiguous;
  • handler bisa menerjemahkan error yang salah.

Lebih baik:

-- safe code

BEGIN
    INSERT INTO case_external_reference(case_id, external_ref)
    VALUES (p_case_id, p_external_ref);
EXCEPTION WHEN unique_violation THEN
    GET STACKED DIAGNOSTICS v_constraint = CONSTRAINT_NAME;

    IF v_constraint = 'case_external_reference_external_ref_key' THEN
        RAISE EXCEPTION USING
            ERRCODE = 'P1101',
            MESSAGE = 'external reference already exists',
            DETAIL  = format('external_ref=%s', p_external_ref);
    END IF;

    RAISE;
END;

-- more safe code

Exception boundary harus sedekat mungkin dengan statement yang expected gagal.


25. Failure Mode: Menggunakan Error untuk Normal Branching

Buruk:

BEGIN
    SELECT * INTO STRICT v_case
    FROM enforcement_case
    WHERE id = p_case_id;
EXCEPTION WHEN no_data_found THEN
    INSERT INTO enforcement_case(id, status)
    VALUES (p_case_id, 'DRAFT');
END;

Lebih baik:

INSERT INTO enforcement_case(id, status)
VALUES (p_case_id, 'DRAFT')
ON CONFLICT (id) DO NOTHING;

Atau:

SELECT * INTO v_case
FROM enforcement_case
WHERE id = p_case_id;

IF NOT FOUND THEN
    INSERT INTO enforcement_case(id, status)
    VALUES (p_case_id, 'DRAFT')
    RETURNING * INTO v_case;
END IF;

Exception untuk exceptional path, bukan normal path.


26. Failure Mode: Menangkap Retryable Error Lalu Mengubahnya Menjadi Non-Retryable

Buruk:

EXCEPTION WHEN others THEN
    RAISE EXCEPTION USING
        ERRCODE = 'P9999',
        MESSAGE = 'operation failed';

Jika original error adalah 40001, caller kehilangan sinyal retry.

Lebih baik:

EXCEPTION WHEN others THEN
    GET STACKED DIAGNOSTICS
        v_state = RETURNED_SQLSTATE,
        v_message = MESSAGE_TEXT;

    IF v_state IN ('40001', '40P01', '55P03') THEN
        RAISE;
    END IF;

    RAISE EXCEPTION USING
        ERRCODE = 'P9999',
        MESSAGE = 'operation failed',
        DETAIL  = format('original_sqlstate=%s, original_message=%s', v_state, v_message);

Namun jangan terlalu cepat membungkus others. Biasanya rethrow lebih baik.


27. Failure Mode: Error Code Tanpa Ownership

Custom SQLSTATE tanpa owner akan menjadi sampah organisasi.

Setiap error code harus punya:

  • name;
  • category;
  • owner;
  • retryability;
  • public message;
  • internal detail policy;
  • expected caller action;
  • test case.

Template registry review:

| SQLSTATE | Name | Category | Retryable | Caller Action | Owner |
|---|---|---|---|---|---|
| P2401 | case_transition_rejected | workflow | no | show allowed action error | case-platform |

Kalau tidak bisa mengisi tabel ini, jangan buat error code baru.


28. Testing Error Contract

Error handling wajib dites. Bukan hanya happy path.

Contoh simple test tanpa framework:

DO $$
BEGIN
    PERFORM app.transition_case_status(
        '00000000-0000-0000-0000-000000000001'::uuid,
        '00000000-0000-0000-0000-000000000002'::uuid,
        'ANALYST',
        'CLOSED',
        NULL,
        1
    );

    RAISE EXCEPTION 'expected case_not_found was not raised';
EXCEPTION
    WHEN SQLSTATE 'P2001' THEN
        RAISE NOTICE 'ok: case_not_found raised';
END;
$$;

Lebih baik dengan pgTAP nanti di part testing, tetapi prinsipnya sama:

  • test SQLSTATE;
  • test message seperlunya;
  • test detail jika menjadi contract;
  • test retryable vs non-retryable behavior;
  • test constraint translation;
  • test unknown constraint rethrow.

29. Review Checklist: Error Handling

Gunakan checklist ini saat review function/procedure besar.

Contract

  • Apakah public-facing function punya error surface yang jelas?
  • Apakah custom SQLSTATE terdaftar?
  • Apakah retryability jelas?
  • Apakah caller action jelas?

Exception Boundary

  • Apakah EXCEPTION block sedekat mungkin dengan statement yang expected gagal?
  • Apakah WHEN others selalu rethrow kecuali contract function memang item-level best-effort?
  • Apakah retryable error tidak dihancurkan?
  • Apakah unknown constraint di-rethrow?

Diagnostic

  • Apakah GET STACKED DIAGNOSTICS dipakai saat menerjemahkan constraint?
  • Apakah error detail cukup untuk debugging?
  • Apakah sensitive data tidak masuk message/detail/log?
  • Apakah correlation id tersedia bila operasi kompleks?

Data Integrity

  • Apakah invariant kritis tetap dijaga constraint?
  • Apakah PL/pgSQL validation tidak menjadi satu-satunya guardrail?
  • Apakah mutation membuktikan outcome dengan ROW_COUNT atau RETURNING?

Operations

  • Apakah batch failure punya table result yang jelas?
  • Apakah failure audit tidak diasumsikan bertahan jika transaksi rollback?
  • Apakah error bisa diagregasi by SQLSTATE?

30. Mini Pattern Library

30.1 Not Found

IF NOT FOUND THEN
    RAISE EXCEPTION USING
        ERRCODE = 'P2001',
        MESSAGE = 'case not found',
        DETAIL = format('case_id=%s', p_case_id);
END IF;

30.2 Stale Version

RAISE EXCEPTION USING
    ERRCODE = 'P2402',
    MESSAGE = 'case update rejected because version is stale',
    DETAIL = format(
        'case_id=%s, expected_version=%s, actual_version=%s',
        p_case_id,
        p_expected_version,
        v_actual_version
    ),
    HINT = 'Reload the entity and retry.';

30.3 Constraint Translation

EXCEPTION WHEN unique_violation THEN
    GET STACKED DIAGNOSTICS v_constraint = CONSTRAINT_NAME;

    IF v_constraint = 'case_external_reference_external_ref_key' THEN
        RAISE EXCEPTION USING
            ERRCODE = 'P1101',
            MESSAGE = 'external reference already exists',
            DETAIL = format('external_ref=%s', p_external_ref);
    END IF;

    RAISE;

30.4 Diagnostic + Rethrow

EXCEPTION WHEN others THEN
    GET STACKED DIAGNOSTICS
        v_state = RETURNED_SQLSTATE,
        v_message = MESSAGE_TEXT,
        v_context = PG_EXCEPTION_CONTEXT;

    RAISE WARNING 'operation failed: state=%, message=%', v_state, v_message;
    RAISE;

30.5 Batch Item Capture

BEGIN
    PERFORM app.process_one_item(p_item_id);
EXCEPTION WHEN others THEN
    GET STACKED DIAGNOSTICS
        v_state = RETURNED_SQLSTATE,
        v_message = MESSAGE_TEXT;

    INSERT INTO batch_item_result(item_id, status, sqlstate, error_message)
    VALUES (p_item_id, 'FAILED', v_state, left(v_message, 500));
END;

31. Closing Mental Model

PL/pgSQL error handling yang matang memiliki tiga kualitas:

  1. Precise — error yang berbeda punya kode dan meaning berbeda.
  2. Preserving — error asli tidak dihancurkan kecuali memang diterjemahkan secara sadar.
  3. Operational — manusia dan mesin bisa mengambil tindakan dari error tersebut.

Jangan berpikir “bagaimana agar function tidak error”. Pikirkan:

  • error apa yang merupakan hasil valid?
  • error apa yang merupakan bug?
  • error apa yang retryable?
  • error apa yang harus terlihat di audit?
  • error apa yang tidak boleh dibocorkan?
  • error apa yang harus dibiarkan sebagai PostgreSQL native error?

Top 1% engineer tidak menulis handler paling banyak. Mereka menulis failure contract paling jelas.


Next Part

Part berikutnya memperluas topik ini ke observability: RAISE NOTICE, structured debug signal, logging boundary, diagnostic context, dan bagaimana membuat PL/pgSQL bisa dioperasikan di production tanpa menebak isi black box.

Lesson Recap

You just completed lesson 11 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.