Home / Programming Languages, Databases, and Software Development / Semantik HTTP untuk …

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 Allow wajib diisi.
  • 501 hanya untuk metode yang sama sekali tidak dikenali server; ServeMux Go 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-cache berarti “simpan tapi revalidasi”, bukan “jangan simpan”; yang melarang penyimpanan adalah no-store.
  • PUT, DELETE, dan semua metode aman bersifat idempoten sehingga aman di-retry; POST tidak.

Versi dan Lingkungan Pengujian

KomponenNilai TerujiKeterangan
Go1.27.1 (linux/amd64)gofmt -l bersih, go vet bersih
Routingnet/http.ServeMuxPola metode GET /path/{id} sejak Go 1.22
Klien ujicurlSemua respons di bawah hasil eksekusi nyata
Listen127.0.0.1:8090Server contoh di artikel
StandarRFC 9110 dan RFC 9111 (Juni 2022)Status Track, menggantikan RFC 7230-7235

Angka dan keluaran di artikel ini diambil dari eksekusi lokal, bukan dari dokumentasi. RFC 9110 dan RFC 9111 adalah standar semantik HTTP yang berlaku per Juni 2022; RFC 9110 membatalkan definisi status code versi lama di RFC 7231. Semua kutipan di bawah diambil dari teks RFC tersebut.

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.

go
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:

bash
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:8090

Status 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:

bash
curl -sS -o - -w '\nstatus=%{http_code}\n' http://127.0.0.1:8090/api/lama
text
{"error":"endpoint sudah dihapus"}
status=410

Perlu 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.

bash
curl -sS -D - -X POST -o /dev/null http://127.0.0.1:8090/api/versi
text
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, OPTIONS

Nah, ini hasil yang mengejutkan dan layak dicatat. Saya mengirim metode yang tidak dikenal sama sekali, dan Go tidak menjawab 501:

bash
curl -sS -D - -X FLY -o /dev/null http://127.0.0.1:8090/api/pembeli/1
text
HTTP/1.1 405 Method Not Allowed
Allow: DELETE, GET, HEAD, PUT

ServeMux 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:

PermintaanRespons terverifikasi
Metode tak dikenal, path terdaftar405 dengan Allow
Metode tak dikenal, path tak terdaftar404

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.

bash
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
text
{"error":"ID sudah dipakai, kirim If-Match untuk menimpa"}
status=409
{"error":"ETag tidak cocok"}
status=412

Cara 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.

bash
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
text
{"error":"wajib application/json"}
status=415
SituasiStatus benar
Content-Type: text/plain ke endpoint JSON415
Content-Type: application/json, body JSON rusak400
Content-Type: application/json, JSON valid tapi field tidak valid422
Accept tidak punya tipe yang cocok406

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.”

MetodeIdempotenAlasan
GET, HEAD, OPTIONSYaMetode aman, tidak mengubah state
PUTYaMenetapkan state resource, mengulangnya tidak mengubah hasil
DELETEYaMenghapus resource, mengulangnya tetap tidak ada
POSTTidakSatu permintaan bisa membuat tiga resource berbeda
PATCHTergantungBergantung 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:

bash
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/1
text
PUT    = 204
DELETE = 204

Perhatikan bedanya dengan POST yang membuat resource dan harus menyertakan Location:

bash
curl -sS -D - -X POST -H 'Content-Type: application/json' \
  -d '{"nama":"Budi"}' -o /dev/null http://127.0.0.1:8090/api/pembeli
text
HTTP/1.1 201 Created
Cache-Control: no-store
Location: /api/pembeli/2

ETag 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.

bash
curl -sS -D - -o /dev/null http://127.0.0.1:8090/api/pembeli/1
text
HTTP/1.1 200 OK
Cache-Control: private, max-age=0, must-revalidate
Content-Type: application/json
Etag: "13d3a6fb5a804db4"
Vary: Accept-Encoding
Content-Length: 22
bash
curl -sS -D - -o /dev/null \
  -H 'If-None-Match: "13d3a6fb5a804db4"' http://127.0.0.1:8090/api/pembeli/1
text
HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=0, must-revalidate
Etag: "13d3a6fb5a804db4"
Vary: Accept-Encoding

Dua 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:

bash
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/1
text
status=200 bytes=22

HEAD 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-Match selalu 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.

DirectiveArti praktisBerlaku untuk
no-storeJangan simpan sama sekaliCache bersama dan pribadi
no-cacheBoleh simpan, wajib revalidasi duluCache bersama dan pribadi
privateCache bersama dilarang menyimpanCache pribadi boleh
max-age=NSegar selama N detikKeduanya
s-maxage=NMenggantikan max-age dan ExpiresCache bersama saja
must-revalidateSetelah stale, wajib validasi sebelum dipakaiKeduanya

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:

bash
curl -sS -D - -o /dev/null http://127.0.0.1:8090/api/laporan
text
HTTP/1.1 200 OK
Age: 12
Cache-Control: public, max-age=60, s-maxage=300
Vary: Accept-Encoding

Age 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 pada PUT dan DELETE tidak mengirim body.
  • Endpoint yang aman di-retry memakai PUT atau DELETE, atau POST dengan idempotency key.
  • ETag dan If-None-Match dipakai bersama, dan 304 mengulang header yang diwajibkan RFC.
  • Cache-Control ditulis eksplisit, dengan no-store untuk data sensitif.
  • Vary mencantumkan 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.