Skip to content

400 atau 404: Status Error Tepat dari Service Layer Go

Adityo Guni Waluyo

Permintaan create dengan category_slug tak dikenal dijawab 400, bukan 404: sentinel error memisahkan nilai input yang salah dari resource yang hilang.

Ringkasan

Klien mengirim request Create tapi dapat HTTP 400. Error karena category_slug tidak dikenal. Klien harus memperbaiki nilai input.

Permintaan Create untuk organisasi baru mengembalikan HTTP 400. Body responsnya hanya satu baris: category_slug "pemuda-pelajar" tidak dikenal. Insting pertama menolak angka itu. Slug tidak ada di basis data, dan selama ini semua nilai yang tidak ada dijawab 404.

Kecurigaan itu masuk akal sampai disadari bahwa yang dikirim bukan alamat yang salah, melainkan isi body yang salah. Dua jenis kesalahan itu terlihat mirip di permukaan. Keduanya sama-sama berarti ada sesuatu yang tidak ditemukan. Yang membedakan adalah objek mana yang tidak ditemukan, dan siapa yang harus bertindak setelah respons itu keluar.

URL yang dituju, koleksi organisasi, ada dan menerima POST. Yang tidak ada adalah nilai category_slug di dalam body. Klien mengirim referensi yang tidak valid. Itu kegagalan validasi pada input, bukan alamat yang tidak ditemukan.

Kekeliruan membaca status muncul karena kebiasaan lama: semua yang tidak ada dianggap satu jenis. Padahal ketiadaan punya dua wajah. Satu ketiadaan pada alamat, satu ketiadaan pada nilai.

Batas antara input salah dan alamat tidak ada

MDN mendefinisikan 400 Bad Request sebagai kondisi ketika server tidak dapat memproses request karena sesuatu yang dianggap kesalahan klien: sintaks salah, framing pesan keliru, atau routing request yang menyesatkan [3]. Status 404 Not Found muncul ketika origin server tidak menemukan representasi target saat ini, atau tidak ingin mengungkapkan bahwa target itu ada [4]. Dua definisi itu berbicara tentang objek yang berbeda: satu tentang isi pesan, satu tentang target yang dialamatkan.

Di service ini, target yang dialamatkan adalah resource di URL. GetByID menuju alamat organisasi tertentu. Jika id itu tidak ada di repository, target yang dialamatkan memang hilang, dan 404 jawaban yang benar. Create menuju koleksi yang ada. category_slug adalah field di dalam body, sebuah referensi ke resource lain. Field yang menunjuk ke sesuatu yang tidak eksis adalah nilai yang gagal divalidasi, bukan alamat yang gagal ditemukan.

Validator service memeriksa nama, category, status, dan daftar anggota. Semua kegagalan pada lapisan itu berasal dari nilai input yang dikirim klien. Bentuk kesalahannya seragam. Walaupun field yang salah berbeda-beda, kategorinya satu: masukan tidak sah, dan itu diterjemahkan menjadi 400.

Mengembalikan 404 untuk kasus ini akan menyesatkan klien. Klien membaca 404 sebagai tanda bahwa endpoint atau id yang dia tuju tidak ada, lalu berhenti dan memeriksa URL, padahal URL-nya benar. Klien justru perlu tahu bahwa yang salah adalah nilai yang dia isi, supaya dia bisa memperbaikinya dan mengirim ulang.

Sentinel error dan errors.Is

Keputusan berikutnya adalah bagaimana service memberi tahu handler kategori kesalahan tanpa membocorkan detail ke lapisan atas. Pilihan yang diambil: satu sentinel error bersama, ErrValidation, dideklarasikan di paket service. Repository memiliki sentinel sendiri, ErrNotFound. Keduanya dipakai sebagai identitas, bukan sebagai pesan yang dibaca manusia.

Go 1.13 memperkenalkan errors.Is dan pembungkusan error dengan kata kunci %w, sehingga sebuah error bisa membawa konteks sekaligus tetap dikenali identitasnya di rantai [1]. Dokumentasi paket errors menegaskan pola yang sama: bungkus dengan %w untuk mempertahankan rantai, uji dengan errors.Is untuk membandingkan tanpa peduli seberapa dalam error itu dibungkus [2].

Perbedaan pesan dan identitas inilah inti polanya. Pesan boleh berubah, identitas tetap.

// service
var ErrValidation = errors.New("validation failed")

func (s *OrgService) Create(ctx context.Context, in CreateInput) (*Org, error) {
    if !s.categoryExists(ctx, in.CategorySlug) {
        return nil, fmt.Errorf("category_slug %q: %w", in.CategorySlug, ErrValidation)
    }
    return s.repo.Insert(ctx, in)
}

// handler
switch {
case errors.Is(err, service.ErrValidation):
    writeStatus(w, http.StatusBadRequest)
case errors.Is(err, repository.ErrNotFound):
    writeStatus(w, http.StatusNotFound)
}

Handler tidak perlu tahu slug mana yang salah. Ia hanya perlu tahu kategori kesalahannya. Pesan detailnya tetap ikut di rantai error jika ada lapisan yang ingin mencatat log.

Kenapa sentinel, bukan tipe error khusus

Alternatif pertama yang ditolak: tipe error khusus untuk setiap kasus validasi. Pendekatan itu menambah tipe baru setiap kali aturan bertambah, padahal semuanya berujung pada status yang sama. Alternatif kedua yang ditolak: mencocokkan string pesan error. Cara itu rapuh, sebab satu perubahan kalimat akan memutus percabangan handler tanpa peringatan dari compiler.

Sentinel menempatkan kategori di satu titik dan detail di rantai error. Dua ember sudah cukup untuk kebutuhan sekarang: nilai input salah menjadi ErrValidation menuju 400, ketiadaan di repository menjadi ErrNotFound menuju 404. Pembagian ini membuat kepemilikan jelas. Service bertanggung jawab atas validasi, repository bertanggung jawab atas keberadaan data.

Keputusan yang dipegang: hanya kesalahan nilai input di lapisan service yang dibungkus ErrValidation, dan hanya repository yang boleh mengembalikan ErrNotFound. Satu hal yang belum diputuskan: referensi yang lolos validasi tetapi hilang saat insert karena race. Untuk sekarang kasus itu tetap jatuh ke 400, sebab jendela racenya sempit dan klien memang harus mengirim ulang. Batas itu bisa ditinjau lagi jika keluhan mulai muncul.

Sumber:

Artikel terkait