Semantik HTTP untuk Developer: Status Code, Header Cache, dan Idempotensi REST API
Dua API bisa mengembalikan JSON yang persis sama dan tetap salah, dan yang satu lagi bisa salah juga, karena bedanya ada di status code dan header yang tidak terlihat di body. Artikel ini menguji setiap klaim dengan satu server Go kecil dan curl, jadi Anda bisa membaca respons mentahnya, bukan deskripsi behavior.
Ringkasan
- 405 berarti metode dikenali tapi terlarang pada resource itu, dan header
Allowwajib diisi. - 501 hanya untuk metode yang sama sekali tidak dikenali server;
ServeMuxGo menjawab 405, bukan 501. - 410 hanya untuk kondisi yang pasti permanen; kalau tidak tahu, 404 yang benar.
- 409 untuk konflik state resource, 412 untuk prekondisi header yang bernilai false.
- 304 adalah hasil sukses, dan server wajib mengulang
ETag,Vary,Date,Content-Location. no-cacheberarti “simpan tapi revalidasi”, bukan “jangan simpan”; yang melarang penyimpanan adalahno-store.PUT,DELETE, dan semua metode aman bersifat idempoten sehingga aman di-retry;POSTtidak.
Versi dan Lingkungan Pengujian
| Komponen | Nilai Teruji | Keterangan |
|---|---|---|
| Go | 1.27.1 (linux/amd64) | gofmt -l bersih, go vet bersih |
| Routing | net/http.ServeMux | Pola metode GET /path/{id} sejak Go 1.22 |
| Klien uji | curl | Semua respons di bawah hasil eksekusi nyata |
| Listen | 127.0.0.1:8090 | Server contoh di artikel |
| Standar | RFC 9110 dan RFC 9111 (Juni 2022) | Status Track, menggantikan RFC 7230-7235 |
Server Uji: 107 Baris Go yang Menyusun Semantik
Saya membangun satu server kecil yang mengimplementasikan status code dan header cache secara sadar, supaya setiap tabel di artikel ini bisa diuji ulang.
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
)
type Item struct {
Body string
}
var (
mu sync.Mutex
store = map[string]Item{
"/api/pembeli/1": {Body: `{"id":1,"nama":"Rina"}`},
}
)
func etagOf(s string) string {
sum := sha256.Sum256([]byte(s))
return `"` + hex.EncodeToString(sum[:])[:16] + `"`
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/pembeli/{id}", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
item, ok := store[r.URL.Path]
mu.Unlock()
if !ok {
http.Error(w, `{"error":"pembeli tidak ditemukan"}`, http.StatusNotFound)
return
}
tag := etagOf(item.Body)
w.Header().Set("ETag", tag)
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
w.Header().Set("Vary", "Accept-Encoding")
if match := r.Header.Get("If-None-Match"); match != "" && match == tag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, item.Body)
})
mux.HandleFunc("POST /api/pembeli", func(w http.ResponseWriter, r *http.Request) {
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
http.Error(w, `{"error":"wajib application/json"}`, http.StatusUnsupportedMediaType)
return
}
w.Header().Set("Location", "/api/pembeli/2")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, `{"id":2,"nama":"Budi"}`)
})
mux.HandleFunc("PUT /api/pembeli/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-Match") == `"versi-lama"` {
http.Error(w, `{"error":"ETag tidak cocok"}`, http.StatusPreconditionFailed)
return
}
if r.URL.Path == "/api/pembeli/1" {
http.Error(w, `{"error":"ID sudah dipakai, kirim If-Match untuk menimpa"}`, http.StatusConflict)
return
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("DELETE /api/pembeli/{id}", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("GET /api/laporan", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300")
w.Header().Set("Age", "12")
w.Header().Set("Vary", "Accept-Encoding")
fmt.Fprint(w, `{"total":42}`)
})
mux.HandleFunc("/api/lama", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusGone)
fmt.Fprint(w, `{"error":"endpoint sudah dihapus"}`)
})
mux.HandleFunc("/api/versi", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", "GET, HEAD, OPTIONS")
http.Error(w, `{"error":"metode tidak didukung"}`, http.StatusMethodNotAllowed)
})
srv := &http.Server{
Addr: "127.0.0.1:8090",
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Println("server berjalan di", srv.Addr)
log.Fatal(srv.ListenAndServe())
}Build dan jalankan:
gofmt -l . # tidak ada keluaran = format rapi
go vet ./...
go build -o semantik .
./semantik
# 2026/09/27 17:21:07 server berjalan di 127.0.0.1:8090Status Code yang Paling Sering Tertukar
Empat pasang berikut adalah tempat API publik paling sering salah, dan keempatnya punya definisi tegas di RFC 9110.
404 versus 410 Gone
Perbedaannya bukan “dihapus” versus “tidak ada”, melainkan apakah server tahu kondisinya permanen. RFC 9110 §15.5.11 menegaskan: “If the origin server does not know, or has no facility to determine, whether or not the condition is permanent, the status code 404 (Not Found) ought to be used instead.”
Endpoint yang sudah tidak dipakai lagi di server uji saya mengembalikan 410, lengkap dengan body JSON agar klien bisa menampilkan pesan yang jelas:
curl -sS -o - -w '\nstatus=%{http_code}\n' http://127.0.0.1:8090/api/lama{"error":"endpoint sudah dihapus"}
status=410Perlu dicatat bahwa 404, 405, 410, dan 501 semuanya heuristically cacheable menurut RFC, artinya cache boleh menyimpannya meski Anda tidak memasang Cache-Control apa pun. Untuk API yang datanya sering berubah, itu alasan kuat untuk memasang kontrol cache secara eksplisit.
405 versus 501 Not Implemented
Keduanya sering tertukar, tetapi definisinya berlawanan arah. 405 berarti metode dikenali tetapi tidak didukung resource tersebut, dan RFC 9110 §15.5.6 mewajibkan header Allow di dalamnya. 501 berarti server tidak mengenali metode itu sama sekali, untuk resource apa pun.
curl -sS -D - -X POST -o /dev/null http://127.0.0.1:8090/api/versiHTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, OPTIONSNah, ini hasil yang mengejutkan dan layak dicatat. Saya mengirim metode yang tidak dikenal sama sekali, dan Go tidak menjawab 501:
curl -sS -D - -X FLY -o /dev/null http://127.0.0.1:8090/api/pembeli/1HTTP/1.1 405 Method Not Allowed
Allow: DELETE, GET, HEAD, PUTServeMux Go modern melihat pola GET /api/pembeli/{id}, tahu path-nya cocok, lalu menolak metodenya dengan 405 beserta daftar metode yang sah. Pada path yang tidak terdaftar, jawabannya 404:
| Permintaan | Respons terverifikasi |
|---|---|
| Metode tak dikenal, path terdaftar | 405 dengan Allow |
| Metode tak dikenal, path tak terdaftar | 404 |
Jadi kalau Anda memakai router modern, 405 adalah jawaban yang realistis dan andalkan. 501 lebih sering muncul dari server yang punya daftar metode eksplisit.
409 versus 412 Precondition Failed
Keduanya soal “tidak bisa diterapkan”, tetapi sumbernya berbeda. 409 melaporkan konflik dengan state resource saat ini, dan klien bisa memperbaikinya lalu mengirim ulang. 412 melaporkan bahwa kondisi di header prekondisi bernilai false saat diuji server.
curl -sS -o - -w '\nstatus=%{http_code}\n' -X PUT http://127.0.0.1:8090/api/pembeli/1
curl -sS -o - -w '\nstatus=%{http_code}\n' -X PUT -H 'If-Match: "versi-lama"' http://127.0.0.1:8090/api/pembeli/1{"error":"ID sudah dipakai, kirim If-Match untuk menimpa"}
status=409
{"error":"ETag tidak cocok"}
status=412Cara praktis memilihnya: 412 untuk alur optimistic locking (klien mengirim If-Match, server menolak karena versi sudah berubah), 409 untuk konflik struktural seperti ID yang sudah dipakai atau versi yang saling meniadakan.
415 versus 422 Unprocessable Content
RFC 9110 §15.5.21 memformat perbedaan ini dengan rapi: 422 berarti “the server understands the content type of the request content (hence a 415 status code is inappropriate), and the syntax of the request content is correct, but it was unable to process the contained instructions”. Jadi 415 untuk tipe media yang salah, 422 untuk JSON yang valid secara sintaks tetapi salah secara makna.
curl -sS -o - -w '\nstatus=%{http_code}\n' -X POST \
-H 'Content-Type: text/plain' -d 'nama=Budi' http://127.0.0.1:8090/api/pembeli{"error":"wajib application/json"}
status=415| Situasi | Status benar |
|---|---|
Content-Type: text/plain ke endpoint JSON | 415 |
Content-Type: application/json, body JSON rusak | 400 |
Content-Type: application/json, JSON valid tapi field tidak valid | 422 |
Accept tidak punya tipe yang cocok | 406 |
Metode dan Idempotensi: Kapan Aman di-Retry
Idempotensi menentukan apakah klien boleh mengirim ulang permintaan ketika koneksi putus sebelum respons terbaca. RFC 9110 §9.2.2 mendefinisikannya begini: “A request method is considered “idempotent” if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request.”
| Metode | Idempoten | Alasan |
|---|---|---|
GET, HEAD, OPTIONS | Ya | Metode aman, tidak mengubah state |
PUT | Ya | Menetapkan state resource, mengulangnya tidak mengubah hasil |
DELETE | Ya | Menghapus resource, mengulangnya tetap tidak ada |
POST | Tidak | Satu permintaan bisa membuat tiga resource berbeda |
PATCH | Tergantung | Bergantung pada isi patch, sering tidak idempoten |
Konsekuensi praktisnya besar. RFC yang sama menyatakan: “A client SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent, regardless of the method, or some means to detect that the original request was never applied.” Artinya retry otomatis pada POST bisa menghasilkan transaksi ganda.
Jika Anda membuat endpoint yang sering gagal di tengah jaringan, ada dua pola yang menutup celahnya: berikan setiap POST sebuah idempotency key, atau ubah operasinya menjadi PUT terhadap identifier buatan klien agar server bisa menganggap permintaan berulang sebagai penggantian.
Untuk respons sukses, RFC 9110 §9.3.4 memberi aturan yang tegas pada PUT: server wajib mengirim 201 kalau resource baru dibuat, dan wajib 200 atau 204 kalau resource yang ada diperbarui. Server uji saya memakai 204 karena tidak ada body yang perlu dikirim:
curl -sS -o /dev/null -w 'PUT = %{http_code}\n' -X PUT http://127.0.0.1:8090/api/pembeli/2
curl -sS -o /dev/null -w 'DELETE = %{http_code}\n' -X DELETE http://127.0.0.1:8090/api/pembeli/1PUT = 204
DELETE = 204Perhatikan bedanya dengan POST yang membuat resource dan harus menyertakan Location:
curl -sS -D - -X POST -H 'Content-Type: application/json' \
-d '{"nama":"Budi"}' -o /dev/null http://127.0.0.1:8090/api/pembeliHTTP/1.1 201 Created
Cache-Control: no-store
Location: /api/pembeli/2ETag dan 304: Polling Murah Tanpa Reposisi
ETag adalah label versi untuk representasi resource. Klien mengirimkannya balik lewat If-None-Match; jika masih sama, server menjawab 304 tanpa body sama sekali.
curl -sS -D - -o /dev/null http://127.0.0.1:8090/api/pembeli/1HTTP/1.1 200 OK
Cache-Control: private, max-age=0, must-revalidate
Content-Type: application/json
Etag: "13d3a6fb5a804db4"
Vary: Accept-Encoding
Content-Length: 22curl -sS -D - -o /dev/null \
-H 'If-None-Match: "13d3a6fb5a804db4"' http://127.0.0.1:8090/api/pembeli/1HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=0, must-revalidate
Etag: "13d3a6fb5a804db4"
Vary: Accept-EncodingDua detail yang sering terlewat. Pertama, transfernya benar-benar nol: respons 304 pada pengujian ini 0 byte, sedangkan respons 200 sebesar 22 byte. Kedua, 304 bukan kondisi galat, dan RFC 9110 §15.4.5 mewajibkan server mengirim ulang Content-Location, Date, ETag, dan Vary — keempatnya terlihat pada keluaran di atas.
Kalau tag-nya berbeda, server kembali mengirim 200 beserta body penuh:
curl -sS -o /dev/null -w 'status=%{http_code} bytes=%{size_download}\n' \
-H 'If-None-Match: "salah"' http://127.0.0.1:8090/api/pembeli/1status=200 bytes=22HEAD memberi pasangan yang konsisten: header identik dengan GET, body kosong.
Saat membandingkan
If-None-Match, penerima wajib memakai weak comparison function, karena tag lemah tetap bisa dipakai untuk validasi cache meski data representasi berubah (§13.1.2). Dan dalam urutan prioritas, RFC 9111 §4.3.2 menegaskan: “the If-Match and If-Unmodified-Since conditional header fields are not applicable to a cache, and If-None-Match takes precedence over If-Modified-Since.” Kalau Anda memakai header kondisional,If-None-Matchselalu menang.
Cache-Control: no-cache Bukan Berarti Jangan Simpan
Ini salah paham yang paling mahal biayanya, karena membalik arah kebijakan cache tanpa disengaja. RFC 9111 §5.2.2.4 menyatakan bahwa no-cache tanpa argumen berarti “the response MUST NOT be used to satisfy any other request without forwarding it for validation”. Cache boleh menyimpan, tapi wajib memvalidasi ulang sebelum dipakai. Yang melarang penyimpanan adalah no-store.
| Directive | Arti praktis | Berlaku untuk |
|---|---|---|
no-store | Jangan simpan sama sekali | Cache bersama dan pribadi |
no-cache | Boleh simpan, wajib revalidasi dulu | Cache bersama dan pribadi |
private | Cache bersama dilarang menyimpan | Cache pribadi boleh |
max-age=N | Segar selama N detik | Keduanya |
s-maxage=N | Menggantikan max-age dan Expires | Cache bersama saja |
must-revalidate | Setelah stale, wajib validasi sebelum dipakai | Keduanya |
Urutannya bukan kebetulan. RFC 9111 §4.2.1 menetapkan bahwa cache memakai aturan pertama yang cocok: s-maxage untuk cache bersama, lalu max-age, lalu Expires dikurangi Date, dan baru terakhirnya heuristic freshness. Ada juga aturan ketat: “If directives conflict (e.g., both max-age and no-cache are present), the most restrictive directive should be honored.”
Respons endpoint laporan di server uji memakai kombinasi s-maxage dan Age, dua hal yang sering dipakai bersama di depan CDN:
curl -sS -D - -o /dev/null http://127.0.0.1:8090/api/laporanHTTP/1.1 200 OK
Age: 12
Cache-Control: public, max-age=60, s-maxage=300
Vary: Accept-EncodingAge bernilai 12 berarti cache itu memperkirakan respons sudah bersarang 12 detik sejak dibuat atau divalidasi ulang di origin. Selama masih di bawah s-maxage=300, cache bersama boleh memakainya tanpa bertanya lagi ke server Anda.
Terakhir, Vary menentukan apa yang menjadi bagian dari cache key. Menurut §4.1, cache “MUST NOT use that stored response without revalidation unless all the presented request header fields nominated by that Vary field value match those fields in the original request”. Karena Vary: Accept-Encoding ada di respons ini, cache akan menyimpan terpisah versi gzip dan non-gzip dari URL yang sama.
Daftar Periksa Sebelum Merge
- Status code untuk kondisi umum sudah dipetakan dan diuji, bukan dikira-kira.
- Respons 405 selalu menyertakan header
Allow. - 410 hanya dipakai kalau kondisi benar-benar permanen.
- 409 dan 412 tidak tertukar: konflik state versus prekondisi gagal.
- 201 pada pembuatan resource selalu diikuti
Location; 204 padaPUTdanDELETEtidak mengirim body. - Endpoint yang aman di-retry memakai
PUTatauDELETE, atauPOSTdengan idempotency key. ETagdanIf-None-Matchdipakai bersama, dan 304 mengulang header yang diwajibkan RFC.Cache-Controlditulis eksplisit, denganno-storeuntuk data sensitif.Varymencantumkan setiap header yang memengaruhi isi respons.
Dengan daftar ini, status code dan header bukan lagi tebak-tebakan: setiap pilihan punya rujukan RFC, dan setiap perilaku bisa diuji dengan curl seperti yang saya lakukan di sini.