Klien menghabiskan lebih banyak waktu menangani kegagalan daripada keberhasilan: payload ditolak, resource hilang, state berbentrok, server mengalami gangguan. API yang error-nya asal — kadang string, kadang array, kadang cuma 500 tanpa penjelasan — memaksa tiap pemanggil menulis tebakan sendiri. Chapter penutup ini merangkum pola sepanjang section menjadi desain error yang disiplin: kerangka JSON konsisten, kode mesin stabil, detail validasi terstruktur, dan pesan publik yang aman — dengan pemisahan tegas antara log internal dan respons klien.

Kerangka Error yang Konsisten #

Setiap respons gagal memakai satu bentuk: {"error":{"code":"...","message":"...","details":{...}}}. Objek error wajib berisi code dan message; details opsional, muncul bila ada konteks tambahan. Satu bentuk membuat penanganan klien satu jalur juga: parse body, baca code, bercabang — tidak pernah mem-parsing message.

Kode yang Stabil dan Terbaca Mesin #

Nilai code adalah kontrak, bukan dekorasi. Kode harus berupa string konstan yang stabil antar rilis — VALIDATION_ERROR, bukan angka acak seperti ERR_4002 yang maknanya harus dicari di tabel tersembunyi. Menghapus atau mengganti makna kode adalah perubahan breaking; menambah kode baru aman — pola additive yang sama. Status HTTP tetap dikirim dan harus konsisten dengan kode:

Status HTTPKodeSituasi
400VALIDATION_ERRORpayload gagal aturan validasi
401UNAUTHENTICATEDkredensial hilang atau salah
403FORBIDDENterautentikasi tetapi tanpa izin
404NOT_FOUNDresource tidak ditemukan
409SUDAH_DIBAYARbentrok dengan state saat ini
429RATE_LIMIT_EXCEEDEDbatas laju terlampaui
500INTERNAL_ERRORkegagalan tak terduga di server

Detail Validasi pada details #

Kesalahan validasi paling berguna dilaporkan seluruhnya sekaligus: email salah dan jumlah kosong lebih baik tiba dalam satu respons daripada dua kali bolak-balik. Objek details menampungnya sebagai peta field ke alasan penolakan. Chapter Request Validation memakai kunci bernama fields untuk peta yang sama — nama kunci tambahan adalah keputusan tim; konsistensinya di seluruh API yang terpenting.

Pesan Aman untuk Publik #

message ditujukan untuk dibaca manusia di luar organisasi kita. Pesan yang aman menyebut apa yang terjadi dan arah perbaikannya tanpa membocorkan isi dalam: alamat database, potongan SQL, stack trace, nomor baris. Detail itu tetap dibutuhkan tim operasional, bukan pemanggil — catat ke log server, kirim pesan generik ke klien.

problem+json sebagai Alternatif Standar #

RFC 9457 mendefinisikan format galat HTTP standar bernama problem details dengan media type application/problem+json. Kerangkannya memuat type (URI yang mengidentifikasi jenis galat), title, status, dan detail; tanpa type, nilai bawaannya about:blank. Konvensi custom sama sah; pilih satu dan nyatakan di dokumentasi — kerangka RFC tinggal ditukar di helper bila ekosistem Anda sudah berbicara problem+json.

Program: 400, 404, 409, dan 500 yang Seragam #

Program errdesign-demo.go melayani pesanan dengan empat jenis kegagalan: validasi, resource hilang, konflik state, dan gangguan internal. Keempatnya keluar dari helper yang sama — termasuk kegagalan internal yang detailnya hanya masuk log.

go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
)

// masukanPesanan adalah payload pembuatan pesanan.
type masukanPesanan struct {
	Email  string `json:"email"`
	Jumlah *int   `json:"jumlah"`
}

// Pesanan adalah representasi resource yang dikirim ke klien.
type Pesanan struct {
	ID     int    `json:"id"`
	Email  string `json:"email"`
	Jumlah int    `json:"jumlah"`
	Status string `json:"status"`
}

type amplopSatu struct {
	Data Pesanan `json:"data"`
}

// Handler menyimpan pesanan dan status pembayarannya.
type Handler struct {
	pesanan []Pesanan
}

func (h *Handler) routes() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /pesanan", h.buat)
	mux.HandleFunc("GET /pesanan/{id}", h.detail)
	mux.HandleFunc("POST /pesanan/{id}/bayar", h.bayar)
	mux.HandleFunc("GET /laporan-penjualan", h.laporan)
	return mux
}

// buat memvalidasi payload; seluruh pelanggaran field dilaporkan sekaligus
// di dalam details sebagai peta field ke alasan penolakan.
func (h *Handler) buat(w http.ResponseWriter, r *http.Request) {
	var in masukanPesanan
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		tulisGalat(w, http.StatusBadRequest, "INVALID_JSON", "body bukan JSON yang valid")
		return
	}
	details := map[string]string{}
	if !strings.Contains(in.Email, "@") {
		details["email"] = "harus berisi alamat email yang valid"
	}
	if in.Jumlah == nil || *in.Jumlah <= 0 {
		details["jumlah"] = "harus berupa angka lebih besar dari 0"
	}
	if len(details) > 0 {
		tulisJSON(w, http.StatusBadRequest, galatBody{Error: galatRincian{
			Code:    "VALIDATION_ERROR",
			Message: "payload tidak lolos validasi",
			Details: details,
		}})
		return
	}
	p := Pesanan{ID: len(h.pesanan) + 1, Email: in.Email, Jumlah: *in.Jumlah, Status: "MENUNGGU"}
	h.pesanan = append(h.pesanan, p)
	w.Header().Set("Location", fmt.Sprintf("/pesanan/%d", p.ID))
	tulisJSON(w, http.StatusCreated, amplopSatu{Data: p})
}

// detail mengirim satu pesanan berdasarkan ID.
func (h *Handler) detail(w http.ResponseWriter, r *http.Request) {
	nomor, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		tulisGalat(w, http.StatusBadRequest, "INVALID_ID", "id harus angka")
		return
	}
	for _, p := range h.pesanan {
		if p.ID == nomor {
			tulisJSON(w, http.StatusOK, amplopSatu{Data: p})
			return
		}
	}
	tulisGalat(w, http.StatusNotFound, "NOT_FOUND",
		fmt.Sprintf("pesanan %d tidak ditemukan", nomor))
}

// bayar menolak pembayaran kedua dengan 409 dan status saat ini di details.
func (h *Handler) bayar(w http.ResponseWriter, r *http.Request) {
	nomor, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		tulisGalat(w, http.StatusBadRequest, "INVALID_ID", "id harus angka")
		return
	}
	for i := range h.pesanan {
		if h.pesanan[i].ID != nomor {
			continue
		}
		if h.pesanan[i].Status == "LUNAS" {
			tulisJSON(w, http.StatusConflict, galatBody{Error: galatRincian{
				Code:    "SUDAH_DIBAYAR",
				Message: "pesanan sudah lunas, tidak bisa dibayar dua kali",
				Details: map[string]string{"status": "LUNAS"},
			}})
			return
		}
		h.pesanan[i].Status = "LUNAS"
		tulisJSON(w, http.StatusOK, amplopSatu{Data: h.pesanan[i]})
		return
	}
	tulisGalat(w, http.StatusNotFound, "NOT_FOUND",
		fmt.Sprintf("pesanan %d tidak ditemukan", nomor))
}

// bacaLaporan mensimulasikan kegagalan infrastruktur di balik endpoint.
func bacaLaporan() error {
	return errors.New("koneksi database terputus (dial tcp 10.0.0.5:5432: i/o timeout)")
}

// catatInternal mencatat detail kegagalan hanya di sisi server.
func catatInternal(galat error) {
	fmt.Printf("[log internal] %v\n", galat)
}

// laporan mengirim pesan publik yang aman; detail galat hanya masuk log.
func (h *Handler) laporan(w http.ResponseWriter, r *http.Request) {
	if err := bacaLaporan(); err != nil {
		catatInternal(err)
		tulisGalat(w, http.StatusInternalServerError, "INTERNAL_ERROR",
			"terjadi kesalahan internal, coba beberapa saat lagi")
		return
	}
	tulisJSON(w, http.StatusOK, map[string]string{"data": "laporan penjualan"})
}

type galatBody struct {
	Error galatRincian `json:"error"`
}

type galatRincian struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Details map[string]string `json:"details,omitempty"`
}

func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
	tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}

func tulisJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func main() {
	h := &Handler{}
	mux := h.routes()

	// 400: dua field gagal validasi sekaligus, tercantum di details.
	kirim(mux, "POST", "/pesanan", `{"email":"budi","jumlah":0}`)

	// 201: payload valid, Location menunjuk pesanan baru.
	kirim(mux, "POST", "/pesanan", `{"email":"[email protected]","jumlah":2}`)

	// 200: pembayaran pertama berhasil.
	kirim(mux, "POST", "/pesanan/1/bayar", "")

	// 409: pembayaran kedua ditolak, details memberi konteks state.
	kirim(mux, "POST", "/pesanan/1/bayar", "")

	// 404: pesanan tidak ada.
	kirim(mux, "GET", "/pesanan/99", "")

	// 500: kegagalan internal — klien hanya menerima pesan generik,
	// detail lengkap tercatat di log server.
	kirim(mux, "GET", "/laporan-penjualan", "")
}

// kirim mengeksekusi satu permintaan lalu mencetak status, header Location,
// dan body — memakai httptest agar hasil selalu dapat direproduksi.
func kirim(mux *http.ServeMux, metode, target, body string) {
	var pembaca io.Reader
	if body != "" {
		pembaca = strings.NewReader(body)
	}
	req := httptest.NewRequest(metode, target, pembaca)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	fmt.Printf("%-5s %-20s -> %d", metode, target, rec.Code)
	if loc := rec.Header().Get("Location"); loc != "" {
		fmt.Printf(" (Location: %s)", loc)
	}
	fmt.Println()
	if rec.Body.Len() > 0 {
		fmt.Printf("   %s", rec.Body.String())
	}
}

Output yang Dihasilkan:

text
POST  /pesanan             -> 400
   {"error":{"code":"VALIDATION_ERROR","message":"payload tidak lolos validasi","details":{"email":"harus berisi alamat email yang valid","jumlah":"harus berupa angka lebih besar dari 0"}}}
POST  /pesanan             -> 201 (Location: /pesanan/1)
   {"data":{"id":1,"email":"[email protected]","jumlah":2,"status":"MENUNGGU"}}
POST  /pesanan/1/bayar     -> 200
   {"data":{"id":1,"email":"[email protected]","jumlah":2,"status":"LUNAS"}}
POST  /pesanan/1/bayar     -> 409
   {"error":{"code":"SUDAH_DIBAYAR","message":"pesanan sudah lunas, tidak bisa dibayar dua kali","details":{"status":"LUNAS"}}}
GET   /pesanan/99          -> 404
   {"error":{"code":"NOT_FOUND","message":"pesanan 99 tidak ditemukan"}}
[log internal] koneksi database terputus (dial tcp 10.0.0.5:5432: i/o timeout)
GET   /laporan-penjualan   -> 500
   {"error":{"code":"INTERNAL_ERROR","message":"terjadi kesalahan internal, coba beberapa saat lagi"}}

Mari kita telaah baris demi baris program di atas:

  1. Baris 46-60 (buat, validasi): dua aturan diperiksa berurutan dan hasilnya dikumpulkan ke details; tanpa pengembalian lebih awal, klien menerima seluruh pelanggaran sekaligus.
  2. Baris 61-67 (respons 400): galat validasi mengirim VALIDATION_ERROR beserta peta details — bentuknya tetap kerangka error yang sama.
  3. Baris 69-72 (respons 201): keberhasilan tetap memakai envelope data dari chapter Response Format — konsistensi berlaku untuk sukses dan gagal.
  4. Baris 92-117 (bayar): pembayaran kedua menemukan status LUNAS dan dijawab 409 SUDAH_DIBAYAR; details menyertakan status saat ini.
  5. Baris 119-127 (bacaLaporan, catatInternal): kegagalan infrastruktur dibungkus error berisi detail jaringan; pencatatannya hanya ke sisi server.
  6. Baris 129-138 (laporan): urutannya kunci — catat dulu ke log internal (baris 132), baru kirim 500 INTERNAL_ERROR dengan pesan generik; tidak ada detail teknis yang lolos ke respons.
  7. Baris 140-148 (galatRincian): field Details memakai tag json:"details,omitempty" sehingga kerangka tetap bersih saat konteks tambahan tidak ada.
  8. Baris 150-157 (tulisGalat, tulisJSON): seluruh respons melewati dua helper ini — satu titik kendali bentuk, satu tempat mengubah konvensi.
  9. Baris 160-182 (main): enam permintaan memperagakan 400, 201, 200, 409, 404, dan 500; baris [log internal] dicetak saat handler berjalan, sebelum dump respons.

Output menunjukkan empat body gagal yang tidak bisa dibedakan kecuali isinya. Bandingkan baris [log internal] — alamat IP dan gejala jaringan — dengan body 500 di bawahnya: satu kalimat generik; itulah batas antara log dan pesan publik.

Dengan desain error yang stabil, section REST API tuntas dari routing hingga dokumentasi dan kegagalan. Token statis, hash, dan kebijakan akses akan diperdalam di section Authentication dan Security — tempat token berganti JWT dan verifikasi memakai tanda tangan kriptografi.

Referensi #

Berkas Sumber

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

errdesign-demo.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
)

// masukanPesanan adalah payload pembuatan pesanan.
type masukanPesanan struct {
	Email  string `json:"email"`
	Jumlah *int   `json:"jumlah"`
}

// Pesanan adalah representasi resource yang dikirim ke klien.
type Pesanan struct {
	ID     int    `json:"id"`
	Email  string `json:"email"`
	Jumlah int    `json:"jumlah"`
	Status string `json:"status"`
}

type amplopSatu struct {
	Data Pesanan `json:"data"`
}

// Handler menyimpan pesanan dan status pembayarannya.
type Handler struct {
	pesanan []Pesanan
}

func (h *Handler) routes() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /pesanan", h.buat)
	mux.HandleFunc("GET /pesanan/{id}", h.detail)
	mux.HandleFunc("POST /pesanan/{id}/bayar", h.bayar)
	mux.HandleFunc("GET /laporan-penjualan", h.laporan)
	return mux
}

// buat memvalidasi payload; seluruh pelanggaran field dilaporkan sekaligus
// di dalam details sebagai peta field ke alasan penolakan.
func (h *Handler) buat(w http.ResponseWriter, r *http.Request) {
	var in masukanPesanan
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		tulisGalat(w, http.StatusBadRequest, "INVALID_JSON", "body bukan JSON yang valid")
		return
	}
	details := map[string]string{}
	if !strings.Contains(in.Email, "@") {
		details["email"] = "harus berisi alamat email yang valid"
	}
	if in.Jumlah == nil || *in.Jumlah <= 0 {
		details["jumlah"] = "harus berupa angka lebih besar dari 0"
	}
	if len(details) > 0 {
		tulisJSON(w, http.StatusBadRequest, galatBody{Error: galatRincian{
			Code:    "VALIDATION_ERROR",
			Message: "payload tidak lolos validasi",
			Details: details,
		}})
		return
	}
	p := Pesanan{ID: len(h.pesanan) + 1, Email: in.Email, Jumlah: *in.Jumlah, Status: "MENUNGGU"}
	h.pesanan = append(h.pesanan, p)
	w.Header().Set("Location", fmt.Sprintf("/pesanan/%d", p.ID))
	tulisJSON(w, http.StatusCreated, amplopSatu{Data: p})
}

// detail mengirim satu pesanan berdasarkan ID.
func (h *Handler) detail(w http.ResponseWriter, r *http.Request) {
	nomor, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		tulisGalat(w, http.StatusBadRequest, "INVALID_ID", "id harus angka")
		return
	}
	for _, p := range h.pesanan {
		if p.ID == nomor {
			tulisJSON(w, http.StatusOK, amplopSatu{Data: p})
			return
		}
	}
	tulisGalat(w, http.StatusNotFound, "NOT_FOUND",
		fmt.Sprintf("pesanan %d tidak ditemukan", nomor))
}

// bayar menolak pembayaran kedua dengan 409 dan status saat ini di details.
func (h *Handler) bayar(w http.ResponseWriter, r *http.Request) {
	nomor, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		tulisGalat(w, http.StatusBadRequest, "INVALID_ID", "id harus angka")
		return
	}
	for i := range h.pesanan {
		if h.pesanan[i].ID != nomor {
			continue
		}
		if h.pesanan[i].Status == "LUNAS" {
			tulisJSON(w, http.StatusConflict, galatBody{Error: galatRincian{
				Code:    "SUDAH_DIBAYAR",
				Message: "pesanan sudah lunas, tidak bisa dibayar dua kali",
				Details: map[string]string{"status": "LUNAS"},
			}})
			return
		}
		h.pesanan[i].Status = "LUNAS"
		tulisJSON(w, http.StatusOK, amplopSatu{Data: h.pesanan[i]})
		return
	}
	tulisGalat(w, http.StatusNotFound, "NOT_FOUND",
		fmt.Sprintf("pesanan %d tidak ditemukan", nomor))
}

// bacaLaporan mensimulasikan kegagalan infrastruktur di balik endpoint.
func bacaLaporan() error {
	return errors.New("koneksi database terputus (dial tcp 10.0.0.5:5432: i/o timeout)")
}

// catatInternal mencatat detail kegagalan hanya di sisi server.
func catatInternal(galat error) {
	fmt.Printf("[log internal] %v\n", galat)
}

// laporan mengirim pesan publik yang aman; detail galat hanya masuk log.
func (h *Handler) laporan(w http.ResponseWriter, r *http.Request) {
	if err := bacaLaporan(); err != nil {
		catatInternal(err)
		tulisGalat(w, http.StatusInternalServerError, "INTERNAL_ERROR",
			"terjadi kesalahan internal, coba beberapa saat lagi")
		return
	}
	tulisJSON(w, http.StatusOK, map[string]string{"data": "laporan penjualan"})
}

type galatBody struct {
	Error galatRincian `json:"error"`
}

type galatRincian struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Details map[string]string `json:"details,omitempty"`
}

func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
	tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}

func tulisJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func main() {
	h := &Handler{}
	mux := h.routes()

	// 400: dua field gagal validasi sekaligus, tercantum di details.
	kirim(mux, "POST", "/pesanan", `{"email":"budi","jumlah":0}`)

	// 201: payload valid, Location menunjuk pesanan baru.
	kirim(mux, "POST", "/pesanan", `{"email":"[email protected]","jumlah":2}`)

	// 200: pembayaran pertama berhasil.
	kirim(mux, "POST", "/pesanan/1/bayar", "")

	// 409: pembayaran kedua ditolak, details memberi konteks state.
	kirim(mux, "POST", "/pesanan/1/bayar", "")

	// 404: pesanan tidak ada.
	kirim(mux, "GET", "/pesanan/99", "")

	// 500: kegagalan internal — klien hanya menerima pesan generik,
	// detail lengkap tercatat di log server.
	kirim(mux, "GET", "/laporan-penjualan", "")
}

// kirim mengeksekusi satu permintaan lalu mencetak status, header Location,
// dan body — memakai httptest agar hasil selalu dapat direproduksi.
func kirim(mux *http.ServeMux, metode, target, body string) {
	var pembaca io.Reader
	if body != "" {
		pembaca = strings.NewReader(body)
	}
	req := httptest.NewRequest(metode, target, pembaca)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	fmt.Printf("%-5s %-20s -> %d", metode, target, rec.Code)
	if loc := rec.Header().Get("Location"); loc != "" {
		fmt.Printf(" (Location: %s)", loc)
	}
	fmt.Println()
	if rec.Body.Len() > 0 {
		fmt.Printf("   %s", rec.Body.String())
	}
}