บทเรียน 34 · Day 8 · Status code

Status code — ตัวเลขที่บอกว่าใครผิด

~13 นาที · ต้องมี: บทเรียน 33 (REST: resource + method)

บท 33 สอนว่า URL บอกว่าคุยเรื่องอะไร และ method บอกว่าจะทำอะไรกับมัน — ทั้งสองอย่างเป็นฝั่งคำถาม
บทนี้คือฝั่งคำตอบ: ตัวเลขสามหลักที่ทุกฝ่ายอ่านตรงกัน และเส้นแบ่งเส้นเดียวที่ตัดสินว่าใครต้องตื่นมาแก้ — client หรือ server
ผมจะรัน API เล็ก ๆ ตัวหนึ่งให้ดูจนครบทุกรหัสในบท แล้วจะทำให้เห็นว่า การตอบ 200 พร้อมข้อความ error ทำให้ระบบเงียบสนิทตอนพังได้ยังไง

🧰 บทนี้ไม่ต้องลงอะไรเพิ่มเลย — โค้ดทุกชิ้นใช้ net/http + net/http/httptest ที่ติดมากับ Go อยู่แล้ว go mod init แล้ว go run . ได้ทันที
ผลรันทุกบรรทัดในหน้านี้มาจากเครื่องจริง: go version go1.26.5 darwin/arm64 (ส่วนที่พูดถึง Gin คือ github.com/gin-gonic/gin v1.12.0)

1 · ตัวเลขสามหลักคือ "หัวข้อ" ของคำตอบ ไม่ใช่ของประดับ

มือใหม่เกือบทุกคนคิดแบบนี้ตอนเริ่ม: "ก็ตอบ JSON ไปสิ ในนั้นมี field error อยู่แล้ว ใครอยากรู้ก็อ่านเอา" — ฟังดูสมเหตุสมผล จนกระทั่งนึกออกว่า คนที่อ่านคำตอบของคุณไม่ได้มีแค่โปรแกรมเมอร์คนเดียว

📖 สามคำที่จะใช้ทั้งบท — บทนี้ยังไม่ต้องเขียน server เอง (บท 37 ค่อยเขียน) แต่ต้องรู้จักคำพวกนี้ก่อน:
handler = ฟังก์ชันที่เราเขียนไว้ให้ทำงานเมื่อมี request เข้ามาตรง URL หนึ่ง — หน้าที่ของมันคือประกอบคำตอบ
router (ใน net/http เรียกว่า ServeMux) = ตัวที่ยืนอยู่หน้าสุด ดูว่า request นี้ควรส่งไปให้ handler ตัวไหน
response = คำตอบหนึ่งชุด ประกอบด้วย 3 ส่วน: status code (ตัวเลข 3 หลัก) · header (ข้อมูลกำกับ) · body (เนื้อหา เช่น JSON) — บทนี้โฟกัสส่วนแรก

   handler ของคุณตอบกลับไป
        │
        ├─▶ client library   ดู "ตัวเลข" ก่อนเสมอ แล้วค่อยตัดสินใจว่าจะแกะ body ไหม
        │
        ├─▶ เบราว์เซอร์       ตัดสินใจ cache / redirect / ถาม username จาก "ตัวเลข"
        │
        ├─▶ proxy · load balancer  นับ error rate จาก "ตัวเลข" (มันไม่แกะ body ให้คุณ)
        │
        ├─▶ monitoring / alert    กราฟ 4xx / 5xx ที่ทีม on-call จ้องอยู่ = "ตัวเลข" ล้วน
        │
        └─▶ คน                คนเดียวในลิสต์นี้ที่อ่าน body เป็น

(proxy กับ load balancer คือเครื่องที่ยืนคั่นกลางระหว่าง client กับ server ของเรา คอยส่งต่อ request — ยังไม่ต้องรู้ลึกกว่านี้ในบทนี้)
ทุกคนในภาพยกเว้นบรรทัดสุดท้าย ไม่รู้จัก field error ของคุณเลย พวกเขารู้จักแค่ตัวเลข เพราะตัวเลขคือส่วนเดียวของคำตอบที่มีความหมายมาตรฐาน ไม่ว่าจะเขียนด้วยภาษาอะไร ทีมไหน บริษัทไหน
ดังนั้นกฎข้อแรกของบทนี้คือ: ตัวเลขคือคำตอบที่เครื่องอ่าน · body คือคำตอบที่คนอ่าน — ต้องพูดตรงกันเสมอ

2 · อ่านหลักแรกให้เป็น แล้วที่เหลือเดาได้ครึ่งทาง

status code มี 3 หลัก แต่หลักแรกหลักเดียวบอกได้แล้วว่าเรื่องนี้จบยังไง สองหลักหลังเป็นแค่รายละเอียดว่า "จบแบบไหน"

กลุ่มแปลว่าในงานจริงเจอตอนไหน
1xx รับทราบแล้ว ยังไม่จบ แทบไม่ได้เขียนเอง — เป็นเรื่องของ protocol (ข้อตกลงกลางว่าสองฝั่งจะคุยกันด้วยรูปแบบไหน) เช่นตอน upgrade ไป WebSocket
2xx สำเร็จ เส้นทางปกติของ handler ที่ทำงานได้
3xx ของอยู่ที่อื่น ตามไปต่อ redirect · ส่วนใหญ่ framework จัดการให้เอง (หัวข้อ 8)
4xx คำขอมีปัญหา — client ต้องแก้ ส่งข้อมูลผิด · ไม่ได้ล็อกอิน · ขอของที่ไม่มี
5xx คำขอไม่มีปัญหา แต่ server ทำให้ไม่ได้ — server ต้องแก้ DB ล่ม · โค้ดพัง · dependency timeout
🎯 หัวใจของทั้งบท

4xx = "คุณส่งมาแบบนี้ ผมทำให้ไม่ได้" · 5xx = "คุณส่งมาถูกแล้ว แต่ผมเองมีปัญหา"
เส้นแบ่งนี้ไม่ใช่เรื่องความสุภาพ มันคือการชี้ตัวคนที่ต้องลงมือแก้
ตอบ 4xx = ส่งงานกลับไปให้คนเรียก API · ตอบ 5xx = รับงานไว้เองและควรมี log ที่ฝั่งคุณเสมอ
ตอบผิดฝั่งเมื่อไหร่ คนที่ถูกตามตัวตอนตีสองก็ผิดคนทันที

🍳 เทียบให้เห็นภาพ — ร้านข้าวแกงหน้าปากซอย

คุณสั่งอาหารกับร้าน แล้วร้านทำให้ไม่ได้ — มันมีสองแบบ และคนที่ต้องลงมือแก้เป็นคนละคนกัน
· คุณสั่งเมนูที่ร้านไม่มี ("ขอซูชิครับ") → แม่ค้าบอกว่าไม่มี คุณต้องเปลี่ยนไปสั่งอย่างอื่นเอง แม่ค้าไม่ต้องแตะเตาเลย = 4xx
  (แต่ถ้ามีคนเดินมาสั่งซูชิกันทั้งวัน แปลว่าป้ายเมนูหน้าร้านสื่อผิด ซึ่งเป็นงานของร้านอยู่ดี — 4xx ที่พุ่งขึ้นจึงไม่ใช่เคสที่ปิดทิ้งได้ มันมักแปลว่าเอกสาร API ผิด หรือเพิ่ง deploy อะไรที่ทำให้ฝั่งเรียกพัง)
· คุณสั่งกะเพราไก่ไข่ดาว ซึ่งมีในเมนูอยู่แล้ว แต่แก๊สหมด → คำสั่งของคุณไม่ผิดสักนิด คนที่ต้องวิ่งไปหาแก๊สคือร้าน = 5xx

คำถามจึงไม่ใช่ "ใครน่าสงสารกว่า" แต่เป็น "ใครต้องลุกไปทำอะไรบางอย่าง"
ตอบเลขผิดกลุ่ม = ส่งงานไปให้ผิดคน

ที่ไม่เหมือนกัน: ที่ร้าน แม่ค้าจะพูดยังไงก็ได้ตามใจ ไม่มีใครจดไว้ แต่ status code มีเครื่องคอยนับอยู่ตลอดเวลา — ตอบผิดกลุ่มไม่ได้แค่ทำให้คนสั่งงง แต่ทำให้กราฟที่ทั้งทีมใช้ดูสุขภาพระบบเพี้ยนไปด้วย ซึ่งเสียหายกว่ามาก
และที่ร้าน คนสั่งเป็นมนุษย์ที่เห็นหน้าแม่ค้าแล้วปรับเองได้ แต่ผู้เรียก API เป็นโปรแกรมที่แตกเส้นทางจากตัวเลขอย่างเดียว — ตอบเลขผิดกลุ่มเมื่อไหร่ มันเดินผิดทางทันทีโดยไม่มีใครทักท้วง

ลองตัวอย่างเดียวกันสองแบบ: ผู้ใช้สมัครสมาชิกด้วยอีเมลที่มีคนใช้แล้ว
ถ้าคุณตอบ 409 Conflict → กราฟ 4xx ขยับ ทีม server มองแล้วรู้ว่า "คนใช้กรอกอีเมลซ้ำเยอะขึ้น" ซึ่งอาจแปลว่าหน้าเว็บสื่อสารไม่ดี — ไม่ใช่ระบบพัง
ถ้าคุณตอบ 500 → กราฟ 5xx พุ่ง alert ดัง คนถูกปลุกมาไล่ดู log แล้วพบว่า "ทุกอย่างปกติ แค่อีเมลซ้ำ" — เสียแรงคนทั้งกะ และที่แย่กว่าคือ ครั้งหน้าที่ 5xx พุ่งจริง ๆ จะไม่มีใครเชื่อกราฟนั้นอีก

🗣 ศัพท์ที่เพิ่งเรียนไป

สิ่งที่เพิ่งเข้าใจไปทั้งหมด วงการเรียกด้วยคำพวกนี้ · ใช้คำให้ตรงแล้วจะคุยกับ dev คนอื่นและถาม AI ได้ตรงจุดกว่าเดิมมาก

client / server
client = ฝั่งที่ขอ (แอปมือถือ หน้าเว็บ หรือโปรแกรมอื่นที่ยิงเข้ามา) · server = ฝั่งที่ตอบ คือโค้ดที่เรากำลังเขียนอยู่นี้ · เวลาเล่าปัญหาให้พูดว่า “client ส่งอะไรมา แล้ว server ตอบอะไรกลับ” จะเห็นภาพกว่าพูดว่า “มันพัง”
status class
“กลุ่มของตัวเลข” ที่ดูจากหลักแรก — 2xx, 4xx, 5xx · ประโยคที่ใช้จริง: “เคสนี้ควรตอบ 4xx หรือ 5xx” ถามแบบนี้กับ senior จะได้คำตอบเร็วกว่าถามลอย ๆ ว่า “ควรตอบเลขอะไรดี”
endpoint
URL หนึ่งเส้นทางที่ API เปิดให้เรียก เช่น POST /users · พูดว่า “endpoint นี้ตอบ 500 อยู่” จะระบุที่เกิดเหตุได้แม่นกว่า “API พัง”
error rate
สัดส่วนคำตอบที่เป็น error เทียบกับคำตอบทั้งหมด · เครื่องวัดจากตัวเลข ไม่ได้แกะ body ดู · ถามได้ว่า “error rate ของ endpoint นี้ตอนนี้เท่าไหร่”
alert
การแจ้งเตือนอัตโนมัติที่ยิงออกมาเองเมื่อกราฟผิดปกติ เช่น 5xx พุ่ง · “alert ดัง” = มีคนถูกเรียกจริง ๆ ไม่ใช่แค่ไฟกะพริบบนจอ
on-call
คนที่ถือเวรรับ alert ตอนระบบมีปัญหา รวมถึงตอนตีสอง · ถ้าได้ยินใครพูดว่า “อย่าทำให้ on-call ตื่นฟรี” เขากำลังพูดถึงเรื่องในหัวข้อนี้ตรง ๆ

3 · เก้าตัวที่ต้องคล่อง — รันจริงทั้งชุดในคราวเดียว

ผมเขียน API เล็ก ๆ ด้วย net/http ตัวเดียว (สมัครสมาชิก + ลบผู้ใช้ที่ให้เฉพาะแอดมิน) แล้วยิงมันด้วย client จริงผ่าน httptest.NewServer (ตัวช่วยที่ติดมากับ Go: เปิด server ชั่วคราวบนพอร์ตสุ่มให้ยิงทดสอบ แล้วปิดทิ้ง — หัวข้อ 10 จะใช้อีกที) — ทุกบรรทัดข้างล่างนี้คือ output ที่ออกมาจริง ไม่ได้พิมพ์เอง:

วิธีอ่าน: บรรทัดที่ขึ้นต้นด้วย $ คือคำขอที่ยิงเข้าไป (เขียนย่อ ๆ ให้อ่านง่าย ไม่ใช่คำสั่งที่พิมพ์ในเทอร์มินัลได้) · บรรทัดที่ขึ้นต้นด้วย -> คือคำตอบที่ได้กลับมา

$ POST /users  body={"email":"new@example.com"}
  -> 201 Created   Location: /users/2
     {"id":"2","email":"new@example.com"}
$ POST /users  body={"email":"new@example.com"}      # อีเมลเดิมซ้ำ
  -> 409 Conflict
     {"error":"email already registered"}
$ POST /users  body={"email":"ไม่ใช่อีเมล"}
  -> 422 Unprocessable Entity
     {"error":"email is not a valid address"}
$ POST /users  body={"email":                        # JSON ขาดครึ่ง
  -> 400 Bad Request
     {"error":"invalid json body"}
$ DELETE /users/1                                # ไม่ส่ง token
  -> 401 Unauthorized   WWW-Authenticate: Bearer realm="api"
     {"error":"missing token"}
$ DELETE /users/1  Authorization: Bearer user-token
  -> 403 Forbidden
     {"error":"admin only"}
$ DELETE /users/1  Authorization: Bearer admin-token
  -> 204 No Content
                                                 # ← body ว่างจริง ๆ
$ DELETE /users/1  Authorization: Bearer admin-token   # ลบซ้ำ ของหายไปแล้ว
  -> 404 Not Found
     {"error":"user not found"}
$ PATCH /users/1  Authorization: Bearer admin-token
  -> 405 Method Not Allowed
     Method Not Allowed                          # ← อันนี้ Go ตอบเอง ไม่ได้เขียน

สังเกตว่า request แทบเหมือนกันหมด — DELETE /users/1 ตัวเดียวกัน ได้ 401, 403, 204 และ 404 ตามลำดับ เพราะสิ่งที่เปลี่ยนคือบริบท: ใครถาม · ถามตอนไหน
นี่คือเหตุผลที่ status code ไม่ใช่ "ตารางที่ต้องท่อง" แต่เป็นคำถามที่ต้องตอบให้ตรง

ผลรันข้างบนมี 422 กับ 405 แถมมาด้วย (สองตัวนี้พูดถึงในหัวข้อ 6 และ 8) — แต่เก้าตัวที่ต้องคล่องจริง ๆ คือตารางข้างล่างนี้ ยึดตารางนี้เป็นหลัก

รหัสตอบเมื่อที่คนใหม่ใช้ผิดบ่อย
200 OKสำเร็จ และมีข้อมูลจะส่งกลับเอาไปใช้กับทุกอย่างรวมทั้งตอน error (หัวข้อ 7)
201 Createdสำเร็จ และเพิ่งสร้างของใหม่ใช้ 200 กับ POST ที่สร้างของ
204 No Contentสำเร็จ และไม่มีอะไรจะส่งกลับส่ง body ตามไปด้วย (หัวข้อ 4)
400 Bad Requestอ่านคำขอไม่รู้เรื่อง — รูปแบบผิดเอาไปใช้แทน 401/403/409 หมดทุกอย่าง
401 Unauthorizedไม่รู้ว่าคุณเป็นใคร — ไม่มี token / token ใช้ไม่ได้สลับกับ 403
403 Forbiddenรู้ว่าคุณเป็นใครแล้ว แต่คุณทำสิ่งนี้ไม่ได้สลับกับ 401
404 Not Foundไม่มี resource ตาม URL นี้ใช้กับ "มีของ แต่คุณไม่มีสิทธิ์ดู" ทั้งที่ควรเป็น 403
409 Conflictคำขอถูกทุกอย่าง แต่ชนกับสถานะปัจจุบันตอบ 400 หรือ 500 แทน
500 Internal Server Errorฝั่งเราพัง — และต้องมี log ที่ฝั่งเราใช้กับความผิดของ client

4 · ฝั่งสำเร็จ: 200 vs 201 vs 204 ต่างกันที่ "มีอะไรกลับไปไหม"

ทั้งสามตัวแปลว่าสำเร็จเหมือนกัน คำถามที่ใช้แยกมีข้อเดียว: หลังทำเสร็จ มีของส่งกลับหรือเปล่า และของนั้นเพิ่งเกิดใหม่ไหม

มี body กลับไป?ใช้กับ
200 OKมีGET /users/1 · PUT /users/1 ที่ตอบของที่แก้แล้ว
201 Createdมี (ของที่เพิ่งสร้าง) + ควรมี header LocationPOST /users
204 No Contentไม่มี — ห้ามมีDELETE /users/1 · PUT ที่ไม่ตอบอะไรกลับ

Location ในคำตอบ 201 คือการบอกว่า "ของที่คุณเพิ่งสร้าง ไปหาได้ที่ URL นี้" ผมรันแล้วได้ Location: /users/2 จริงตามผลในหัวข้อ 3 — client ที่ฉลาดหน่อยจะเอาไปใช้ต่อได้เลยโดยไม่ต้องเดาว่า id อยู่ตรงไหนใน body

ส่วน 204 มีข้อบังคับที่ทำให้คนใหม่งงบ่อยที่สุด: มันห้ามมี body ไม่ใช่ "ไม่ควร" แต่ "ห้าม" ระดับ protocol — แล้วถ้าเราเผลอเขียน body ตามไปล่ะ?

ทายก่อนรัน — handler นี้สั่ง 204 แล้วยัง Write ตามไปอีก

อ่านโค้ดก่อน (เจอครั้งแรก): mux.HandleFunc("GET /x", f) = บอก router ว่า "ถ้ามี GET /x เข้ามา ให้เรียก f" · w (http.ResponseWriter) = ปลายปากกาที่เราใช้เขียนคำตอบออกไป · r = คำขอที่เข้ามา · w.WriteHeader(code) = สั่ง status · w.Write(b) = เขียน body

mux.HandleFunc("GET /x", func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusNoContent)
    n, err := w.Write([]byte(`{"message":"deleted"}`))
    fmt.Printf("[server] w.Write -> n=%d err=%v\n", n, err)
})

ฝั่ง client จะได้ status อะไร body อะไร · และบรรทัด [server] จะพิมพ์ว่าอะไร — มี error ไหม และถ้ามี ใครเห็นมัน

[server] w.Write -> n=0 err=http: request method or response status code does not allow body
[client] status="204 No Content" ContentLength=0

Go บอกคุณแล้ว — แต่บอกผ่านค่าที่แทบไม่มีใครรับ
w.Write คืน (n int, err error) เสมอ และตรงนี้มันคืน error จริง ๆ แต่โค้ดที่เราเขียนกันทุกวัน (fmt.Fprintln(w, ...), json.NewEncoder(w).Encode(v)) ไม่ได้เช็คค่านั้น ผลคือ body หายไปเงียบ ๆ ไม่มี log ไม่มี panic

บทเรียนที่ต้องเก็บ: ถ้าอยากส่งข้อความกลับ อย่าใช้ 204 ใช้ 200 แล้วส่ง body ไป
เลือก 204 เมื่อตั้งใจจะไม่ส่งอะไรกลับเท่านั้น — ไม่ใช่เพราะเห็นว่ามันดูเท่กว่า

5 · 401 vs 403 — "ไม่รู้ว่าคุณเป็นใคร" กับ "รู้แล้ว แต่คุณทำไม่ได้"

คู่นี้สลับกันบ่อยที่สุดในโลก ส่วนหนึ่งเพราะชื่อมันหลอก: 401 ชื่อ Unauthorized แต่ความหมายจริงคือ "unauthenticated" = ยังไม่ได้พิสูจน์ตัวตน ส่วน 403 Forbidden ต่างหากที่แปลว่า "ไม่ได้รับอนุญาต"

401 — ยังไม่รู้ว่าคุณเป็นใคร

$ DELETE /users/1        # ไม่ส่ง token
-> 401 Unauthorized
   WWW-Authenticate: Bearer realm="api"
   {"error":"missing token"}

ทางแก้ของ client: ล็อกอินแล้วลองใหม่
เจอตอน: ไม่มี token · token หมดอายุ · token ปลอม

403 — รู้แล้วว่าคุณเป็นใคร

$ DELETE /users/1
  Authorization: Bearer user-token
-> 403 Forbidden
   {"error":"admin only"}

ทางแก้ของ client: ไม่มี — ล็อกอินใหม่กี่รอบก็ได้ 403 เหมือนเดิม
เจอตอน: สิทธิ์ไม่ถึง · ไม่ใช่เจ้าของข้อมูล

ทำไมต้องแยก? เพราะ frontend ทำคนละอย่างกับสองรหัสนี้
เจอ 401 → เด้งไปหน้า login (หรือใช้ refresh token ต่ออายุแล้วยิงซ้ำ)
เจอ 403 → ต้องขึ้นข้อความ "คุณไม่มีสิทธิ์" ห้ามเด้งไป login เพราะผู้ใช้ล็อกอินอยู่แล้ว การเด้งไปหน้า login จะกลายเป็นลูปที่ผู้ใช้ออกไม่ได้
ตอบสลับสองตัวนี้ = frontend ทำงานผิด ทั้งที่โค้ด frontend ถูก

🛗 เทียบให้เห็นภาพ — ยามหน้าตึกคอนโด

คุณเดินเข้าล็อบบี้คอนโด มีสองสถานการณ์ที่ยามไม่ให้ขึ้นลิฟต์ และมันคนละเรื่องกัน
· คุณยังไม่ได้แตะบัตร — ยามไม่รู้ว่าคุณเป็นใคร ลูกบ้านหรือคนนอก จึงบอกว่า “ขอบัตรก่อนครับ” · ทางแก้มี: ไปเอาบัตรมาแตะ = 401
· คุณแตะบัตรแล้วแต่ไฟขึ้นแดง เพราะบัตรหมดอายุหรือเป็นบัตรปลอม — ยามยังยืนยันไม่ได้ว่าคุณเป็นใคร จึงยังเป็น 401 ไม่ใช่ 403 · จุดตัดของสองรหัสนี้อยู่ที่ “ยืนยันตัวตนสำเร็จหรือยัง” ไม่ใช่ “ยื่นบัตรมาหรือยัง” (ตรงกับตารางข้างบนที่เขียนว่า 401 เจอตอน ไม่มี token · token หมดอายุ · token ปลอม)
· คุณแตะบัตรแล้ว ยามรู้ว่าคุณคือคนห้อง 5/2 แต่คุณกดจะขึ้นดาดฟ้า ที่เปิดให้เฉพาะช่าง ยามบอกว่า “รู้จักคุณครับ แต่ชั้นนั้นขึ้นไม่ได้” · ทางแก้ไม่มี — แตะบัตรใบเดิมอีกร้อยครั้งก็ขึ้นไม่ได้ = 403

นี่คือเหตุผลที่ frontend ต้องแยกสองตัวนี้ให้ออก: เจอ 401 แปลว่า “ไปเอาบัตรมา” จึงเด้งไปหน้า login ได้ · เจอ 403 แปลว่า “มีบัตรแล้วแต่ยังไงก็ไปไม่ได้” การเด้งไปหน้า login จึงกลายเป็นลูปที่ผู้ใช้ออกไม่ได้

ที่ไม่เหมือนกัน: ยามจำหน้าคุณได้ แตะบัตรรอบเดียวตอนเช้าก็เดินเข้าออกได้ทั้งวัน แต่ HTTP ไม่จำอะไรเลยระหว่าง request แต่ละครั้ง — ทุกครั้งที่ยิงเข้ามาต้องแนบ token ไปใหม่เสมอ ไม่งั้นก็ได้ 401 อยู่ดี

💡 WWW-Authenticate คืออะไร — header ที่แนบไปกับ 401 เพื่อบอกว่า "ถ้าจะพิสูจน์ตัวตน ให้ใช้วิธีไหน" ในผลรันข้างบนคือ Bearer realm="api" แปลว่า "ส่ง token มาทาง Authorization: Bearer ..."
เป็นคู่หูประจำตัวของ 401 (403 ไม่มี เพราะไม่มีอะไรให้ลองใหม่) ส่วนเรื่อง header ทั้งชุดจะลงรายละเอียดใน บทเรียน 35

🗣 ศัพท์ที่เพิ่งเรียนไป

สองคำแรกหน้าตาคล้ายกันจนคนใหม่เรียกสลับกันตลอด แต่มันคือ 401 กับ 403 ที่เพิ่งดูไปนี่เอง

authentication (ย่อ authn)
“พิสูจน์ว่าคุณเป็นใคร” = ตอนแตะบัตร · ล้มตรงนี้ตอบ 401 · เวลาถามให้พูดว่า “เคสนี้ล้มที่ authentication หรือ authorization” senior จะรู้ทันทีว่าต้องดูตรงไหน
authorization (ย่อ authz)
“รู้แล้วว่าคุณเป็นใคร แล้วคุณทำสิ่งนี้ได้ไหม” = ตอนกดชั้นดาดฟ้า · ล้มตรงนี้ตอบ 403
token
“บัตรผ่าน” ที่ client แนบมากับทุก request เพื่อบอกว่าตัวเองเป็นใคร · แบบที่เจอบ่อยสุดคือ Bearer token ส่งมาใน header Authorization: Bearer <ค่า>
stateless
“ไม่จำอะไรจากครั้งก่อน” · HTTP เป็นแบบนี้ ทุก request ต้องพกหลักฐานมาเองครบทุกครั้ง · ประโยคที่จะได้ยิน: “HTTP มัน stateless เลยต้องส่ง token ไปทุกครั้ง”
frontend / backend
frontend = ฝั่งที่ผู้ใช้เห็นและกด (เว็บ/แอป) · backend = ฝั่ง server ที่เราเขียน · ประโยคที่ทีมใช้จริง: “backend ตอบ 403 มา frontend เลยไม่ควรเด้ง login”

6 · 400 vs 422 vs 409 — ผิดที่รูปแบบ · ผิดที่ค่า · ผิดที่จังหวะ

ทั้งสามตัวเป็น 4xx เหมือนกัน (client ต้องแก้) ต่างกันที่ คำขอเดินมาได้ไกลแค่ไหนก่อนตาย ผมเรียง handler ให้ตรวจตามลำดับนี้เป๊ะ ๆ แล้วรันจริง:

  request เข้ามา
      │
      ├─ แกะ JSON ไม่ออกเลย ─────────────▶ 400  {"error":"invalid json body"}
      │   (วงเล็บไม่ปิด · ไม่ใช่ JSON)
      │
      ├─ แกะออกแล้ว แต่ค่าไม่ผ่านกฎ ─────▶ 422  {"error":"email is not a valid address"}
      │   (อีเมลไม่มี @ · อายุติดลบ)
      │
      ├─ ค่าถูกทุกอย่าง แต่ชนของที่มีอยู่ ──▶ 409  {"error":"email already registered"}
      │   (อีเมลซ้ำ · ลบของที่ถูกลบไปแล้ว)
      │
      └─ ผ่านหมด ───────────────────────▶ 201  + Location

โครง if ที่เรียงแบบนี้ก็คือ guard clause ตัวเดียวกับที่ฝึกใน บทเรียน 4: ตรวจเคสที่ผิดแล้ว return ทันที เรียงจากผิดหยาบไปผิดละเอียด เส้นทางสำเร็จจึงเหลืออยู่ล่างสุดโดยไม่มี else สักตัว

⚠️ เรื่อง 422 ที่ต้องพูดให้ตรง

422 ไม่ได้อยู่ในสไลด์ของคอร์สนี้ และหลายทีมก็ไม่ใช้มันเลย — ใช้ 400 คลุมทั้ง "JSON พัง" และ "ค่าไม่ผ่าน validation" (การตรวจว่าค่าที่ส่งมาถูกกฎไหม เช่น อีเมลต้องมี @) ก็ไม่ผิด
ที่ผิดจริงคือใช้ไม่คงเส้นคงวา: endpoint นี้ 400 endpoint หน้า 422 ทั้งที่เป็นความผิดแบบเดียวกัน — client เขียนโค้ดรับมือไม่ถูก
สิ่งที่ทีมต้องทำคือตกลงกันครั้งเดียวแล้วเขียนลงเอกสาร API ส่วน 409 ต่างออกไปชัดเจนจนไม่ควรเอาไปรวมกับ 400: มันแปลว่า "คำขอคุณไม่ได้ผิดอะไรเลย แต่โลกตอนนี้ไม่ยอมให้ทำ" — ลองใหม่พรุ่งนี้อาจสำเร็จก็ได้

🔮 เรื่องที่จะย้อนมาหลอนตอนเรียน Gin — ในบทท้าย ๆ ของ Day 8 จะเจอ c.BindJSON ที่เขียน status 400 ให้เองทันทีที่ validate ไม่ผ่าน ทดสอบบน gin v1.12.0 แล้วเขียน c.JSON(422, ...) ต่อ จะได้แบบนี้:

[GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 422
STATUS=400

ขอ 422 แต่ได้ 400 เพราะ status ถูกส่งออกไปแล้ว เปลี่ยนไม่ได้อีก — เก็บไว้ก่อนว่า status เขียนได้ครั้งเดียวต่อหนึ่ง response เดี๋ยวหัวข้อ 9 จะเจอกฎเดียวกันนี้ในฝั่ง net/http

7 · กับดักที่แพงที่สุด: ตอบ 200 พร้อม {"error":"not found"}

นี่คือโค้ดที่เจอในโปรเจกต์จริงบ่อยมาก และมักเกิดจากความหวังดี — "ตอบ 200 ไว้ก่อน เดี๋ยว frontend อ่าน field error เอง จะได้ไม่ต้องมี try/catch"
ผมเขียน client แบบที่ทุกคนเขียนกัน แล้วยิงใส่ server สองตัวที่ body เหมือนกันเป๊ะ ต่างกันแค่ตัวเลข:

ทายก่อนรัน — client ตัวเดียวกันข้างล่างนี้ ยิงใส่ server สองตัวที่ตอบ body {"error":"user not found"} เหมือนกันเป๊ะ ต่างกันแค่ตัวเลข: A ตอบ 200 · B ตอบ 404

func fetchUser(url string) (string, error) {
    resp, err := http.Get(url)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 400 {                    // ← ด่านเดียวที่ client มี
        return "", fmt.Errorf("api error: %s", resp.Status)
    }
    b, _ := io.ReadAll(resp.Body)
    var u struct {
        ID   string `json:"id"`
        Name string `json:"name"`
    }
    json.Unmarshal(b, &u)
    return fmt.Sprintf("โหลดสำเร็จ: id=%q name=%q", u.ID, u.Name), nil
}

ฝั่ง A: err ที่ได้กลับมาเป็นอะไร · แล้วสตริงที่พิมพ์ออกมาหน้าตายังไง — เทียบกับฝั่ง B ที่ body เดียวกันเป๊ะ

=== A: server ตอบ 200 + {"error":"user not found"} ===
err = <nil>
out = โหลดสำเร็จ: id="" name=""

=== B: server ตอบ 404 + {"error":"user not found"} ===
err = api error: 404 Not Found
out =

ฝั่ง A ไม่มี error สักตัว — json.Unmarshal ก็ไม่บ่น เพราะ JSON ถูกต้องตามไวยากรณ์ทุกประการ แค่ไม่มี field id กับ name ที่ struct ต้องการ ผลคือได้ zero value ทั้งคู่
โปรแกรมจึงเดินต่อไปพร้อมกับ user ที่ Name == "" — แล้วไปโผล่บนหน้าเว็บว่า "สวัสดีคุณ  " หรือถูกเซฟลง DB เป็นแถวว่าง

สังเกตว่าฝั่ง B ที่ตอบ 404 ใช้ body เดียวกันเป๊ะ แต่ client จับได้ทันที — สิ่งเดียวที่เปลี่ยนคือตัวเลข

📦 เทียบให้เห็นภาพ — พัสดุที่ขึ้นสถานะว่า “จัดส่งสำเร็จ”

คุณสั่งของออนไลน์ วันรุ่งขึ้นแอปขึ้นสถานะว่า “จัดส่งสำเร็จ” แต่พอเปิดกล่องจริง ในนั้นมีแค่กระดาษแผ่นเดียวเขียนว่า “สินค้าหมด”
· ระบบติดตามพัสดุ อ่านแค่คำว่า “สำเร็จ” → นับว่าส่งถึงแล้ว
· รายงานประจำเดือนของบริษัทขนส่ง ก็ขึ้นสวยงาม ไม่มีเคสตกหล่น
· คนเดียวในโลกที่รู้ว่ามีปัญหา คือคุณที่เปิดกล่อง — และคุณต้องโทรไปบอกเขาเอง

ตอบ 200 พร้อม body ที่มี error อยู่ข้างในก็คือแบบนี้เป๊ะ ๆ: ตัวเลขข้างนอกบอกว่าสำเร็จ ข้างในบอกว่าไม่สำเร็จ แล้วทุกเครื่องที่อ่านแต่ตัวเลขก็เชื่อตัวเลข

ที่ไม่เหมือนกัน: คนเปิดกล่องแล้วรู้ทันทีว่าไม่ได้ของ แต่โปรแกรมที่แกะ JSON ไม่รู้ตัวเลยแม้แต่นิดเดียว — มันเติมค่าว่างให้ทุกช่องแล้วเดินต่อเหมือนปกติ แล้วบางทีก็เอาค่าว่างนั้นไปเซฟลงฐานข้อมูลต่ออีกหลายพันแถวกว่าจะมีคนสังเกตเห็น

ความเสียหายไม่ได้จบแค่ client ตัวเดียว ผมนับ error rate แบบเดียวกับที่ monitoring ทำ (คือดูแค่หลักแรกของ status) ได้ผลนี้:

A(200) -> StatusCode=200  class=2xx  นับเป็น error? false
B(404) -> StatusCode=404  class=4xx  นับเป็น error? true
⚠️ ทำไมกับดักนี้ถึงแพงกว่ากับดักอื่นทั้งหมดในบท

1. client library เชื่อตัวเลข — ไม่ว่าจะเป็น http.Get ของ Go, axios, retrofit หรือ SDK ที่ลูกค้าเขียนเอง ทั้งหมดตัดสินใจจาก status ก่อน เสมอ ตอบ 200 = สั่งให้ทุกตัวคิดว่าสำเร็จ
2. monitoring มองไม่เห็น — กราฟ error rate ของคุณจะเป็นเส้นตรงสวยงาม ตลอดเวลาที่ระบบตอบผิดอยู่ ไม่มี alert ดัง ไม่มีใครรู้จนกว่าลูกค้าจะโทรมา
3. มันพังแบบเงียบและสะสม — bug ที่ crash เราเจอใน 5 นาที ส่วน bug ที่ตอบ 200 เราเจอตอนมีข้อมูลว่าง ๆ ค้างใน DB เป็นหมื่นแถว
→ กฎที่ถือไว้ได้ตลอดชีวิตการทำงาน: ถ้า body มีคำว่า error อยู่ในนั้น ตัวเลขต้องไม่ใช่ 2xx

🗣 ศัพท์ที่เพิ่งเรียนไป

สี่คำนี้คือคำที่ dev ใช้เรียก "อาการ" ที่เพิ่งเห็นไป — รู้คำแล้วจะอธิบายให้คนอื่นฟังได้ในประโยคเดียว

zero value
“ค่าเริ่มต้นเวลาไม่มีใครใส่ค่าให้” ของ Go — string ได้ "", ตัวเลขได้ 0, bool ได้ false · ประโยคที่ใช้ได้เลย: “field นี้ไม่มีใน JSON เลยได้ zero value ไป”
silent failure (พังเงียบ)
พังจริงแต่ไม่มีใครร้อง — ไม่มี error ไม่มี log ไม่มี panic · เวลาเล่าให้ senior ฟังให้พูดว่า “เคสนี้มัน fail silently” เขาจะรู้ทันทีว่าปัญหาคือ “ไม่มีสัญญาณเตือน” ไม่ใช่ “โปรแกรมล้ม”
happy path
เส้นทางที่ทุกอย่างเป็นไปตามแผน · บั๊กแบบในหัวข้อนี้ไม่เคยทำให้ happy path พัง มันซ่อนอยู่ในเส้นทาง error เสมอ — เป็นเหตุผลที่ต้องเทสเคสที่ผิดด้วย ไม่ใช่เทสแต่เคสที่ถูก
monitoring
ระบบที่เก็บตัวเลขของระบบไว้แล้วทำกราฟ/ยิง alert ให้ · ตัวเก็บตัวเลขเช่น Prometheus, Datadog, CloudWatch ส่วน Grafana คือหน้าจอที่เอาตัวเลขจากตัวเก็บพวกนั้นมาวาดเป็นกราฟอีกที (พูดว่า “เก็บ metric ไว้ใน Grafana” กับทีม platform จะโดนแก้) · คำถามที่ถามได้จริง: “monitoring ของเรานับ error จาก status code อย่างเดียวหรือเปล่า” — ถ้าใช่ การตอบ 200 พร้อม error ก็แปลว่ามองไม่เห็นเลย

8 · status ที่คุณไม่ได้สั่ง แต่มันตอบไปแล้ว

ก่อนจะโทษตัวเองว่าเขียน status ผิด ต้องรู้ก่อนว่าไม่ใช่ทุกตัวเลขที่ออกจาก handler ของคุณ บางตัว router ตอบไปตั้งแต่ก่อนถึงโค้ดที่คุณเขียนแล้ว — และของแต่ละเจ้าไม่เหมือนกันด้วย

net/http — method ผิดได้ 405 ฟรี

$ curl -s -i -X POST http://127.0.0.1:8765/hello
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff

Method Not Allowed

ลงทะเบียนไว้แค่ "GET /hello" แต่ Allow โผล่มาเป็น GET, HEAD — Go แถม HEAD ให้เอง

Gin — method ผิดได้ 404 ไม่ใช่ 405

=== default HandleMethodNotAllowed = false ===
GET    /only-post   -> 404  body=404 page not found
DELETE /users/42    -> 404  body=404 page not found

=== ตั้ง r.HandleMethodNotAllowed = true ===
GET    /only-post   -> 405  Allow="POST, PUT"

Gin ปิดไว้เป็น default → path ที่มีอยู่จริงแต่ยิงผิด method ดูเหมือนไม่มี endpoint นี้เลย

ทั้งสองผลรันจริงบน Go 1.26.5 / gin v1.12.0 และเป็นเรื่องที่ต้องรู้ตอน debug: เจอ 404 ทั้งที่แน่ใจว่าเขียน route ไว้แล้ว — คำถามแรกที่ต้องถามคือ "method ตรงไหม" ไม่ใช่ "path พิมพ์ผิดหรือเปล่า"

อีกสองตัวที่โผล่มาเองบ่อย:

9 · เขียนใน Go ยังไงไม่ให้พลาด

อย่าพิมพ์เลขดิบ — net/http มีค่าคงที่ให้ครบทุกตัว ผมพิมพ์ออกมาดูพร้อมชื่อข้อความประจำรหัส:

http.StatusOK                    = 200  StatusText = "OK"
http.StatusCreated               = 201  StatusText = "Created"
http.StatusNoContent             = 204  StatusText = "No Content"
http.StatusBadRequest            = 400  StatusText = "Bad Request"
http.StatusUnauthorized          = 401  StatusText = "Unauthorized"
http.StatusForbidden             = 403  StatusText = "Forbidden"
http.StatusNotFound              = 404  StatusText = "Not Found"
http.StatusConflict              = 409  StatusText = "Conflict"
http.StatusUnprocessableEntity   = 422  StatusText = "Unprocessable Entity"
http.StatusInternalServerError   = 500  StatusText = "Internal Server Error"

http.StatusNotFound ยาวกว่า 404 ก็จริง แต่มันทำสองอย่างที่เลขดิบทำไม่ได้: 1) อ่านแล้วรู้เจตนาทันที — StatusNotFound บอกตัวเองว่าแปลว่าอะไร
2) พิมพ์ชื่อผิดแล้ว compile ไม่ผ่าน — เคาะเป็น http.StatusNotFund คอมไพเลอร์ฟ้องทันทีตั้งแต่ยังไม่ได้รัน
เทียบกับเลขดิบ: เผลอพิมพ์ 440 แทน 404 คอมไพเลอร์ไม่มีทางรู้ เพราะมันก็เป็นแค่ int ตัวหนึ่ง — จะรู้ตอน client บ่นเท่านั้น

ต่อมาคือลำดับที่ห้ามสลับ ทดสอบมาแล้วทั้งสามแบบ:

เขียนแบบนี้ผลจริงที่ client ได้
Header().Set(...) → WriteHeader(201) → Encode(u) ✅ 201 Created + Content-Type: application/json; charset=utf-8
WriteHeader(201) → Header().Set(...) → Encode(u) 201 แต่ Content-Type: text/plain; charset=utf-8 — ตั้งช้าไป header ที่ Set ทีหลังไม่มีผล และไม่มี error ไม่มี log ให้เห็นเลย
เขียน body ก่อน แล้วค่อย WriteHeader(400) ได้ 200 + log ที่ฝั่ง server: http: superfluous response.WriteHeader call from main.main.func8 (main.go:60)

เหตุผลเบื้องหลังทั้งตารางมีข้อเดียว: WriteHeader คือจังหวะที่ status กับ header ถูกส่งออกสายไปแล้ว ของที่ตั้งทีหลังจึงตามไปไม่ทัน (แถว 2) และ Write ครั้งแรก ก็เรียก WriteHeader(200) ให้เองอัตโนมัติถ้ายังไม่เคยเรียก (แถว 3)
แถวสุดท้ายคือต้นตอคลาสสิกของ "ทำไม error ตอบ 200" ที่ไม่ได้เกิดจากตั้งใจเลย — แค่เผลอ fmt.Fprintln(w, ...) ไปก่อนหนึ่งบรรทัด status ก็ถูกล็อกที่ 200 ทันที
ข่าวดีคือ log บอกชื่อฟังก์ชันและเลขบรรทัดมาให้เสร็จ — เห็น superfluous เมื่อไหร่ ให้ตามไปที่บรรทัดนั้นได้เลย
กฎเดียวที่ต้องจำ: Header().Set() → WriteHeader() → Write()/Encode() และเขียน status ได้ครั้งเดียวต่อหนึ่ง response

🧾 เทียบให้เห็นภาพ — เครื่องพิมพ์ใบเสร็จที่เคาน์เตอร์

ใบเสร็จออกจากเครื่องทีละบรรทัดจากบนลงล่าง และกระดาษเลื่อนออกทางเดียว ย้อนกลับไม่ได้
· หัวใบเสร็จ (ชื่อร้าน เลขที่บิล) = status + header ต้องออกมาก่อน
· รายการสินค้าที่ไล่ตามลงมา = body

จากตรงนี้อธิบายทั้งตารางข้างบนได้หมด:
· แคชเชียร์เผลอสั่งพิมพ์รายการสินค้าก่อนโดยยังไม่ได้ตั้งหัวบิล → เครื่องใส่หัวมาตรฐานให้เองอัตโนมัติ ตรงกับที่ Write ครั้งแรก เรียก WriteHeader(200) ให้เองถ้าเรายังไม่เคยสั่ง (แถว 3)
· พอกระดาษเลื่อนพ้นหัวพิมพ์ไปแล้ว จะย้อนขึ้นไปแก้บรรทัดบนไม่ได้อีก ตรงกับที่ header ซึ่ง Set ทีหลัง WriteHeader ไม่มีผล (แถว 2)

ที่ไม่เหมือนกัน: ใบเสร็จพิมพ์ผิด แคชเชียร์ฉีกทิ้งแล้วพิมพ์ใบใหม่ได้ แต่ response ที่ออกสายไปแล้วเรียกคืนไม่ได้เลย — Go จึงทำได้แค่พิมพ์ log เตือนไว้ที่ฝั่งเรา แล้วปล่อยให้ของที่ส่งไปแล้วเป็นคำตอบสุดท้าย

🗣 ศัพท์ที่เพิ่งเรียนไป

คำพวกนี้จะโผล่ในข้อความ error และในคำตอบของ senior/AI ตรง ๆ — อ่านออกแล้วจะแก้เองได้ทันที

http.ResponseWriter
“ปลายปากกา” ที่ handler ใช้เขียนคำตอบออกไป — ทั้ง status, header และ body ออกทางนี้ทางเดียว · ถามได้ว่า “ตรงนี้เขียนอะไรลง ResponseWriter ไปแล้วบ้าง”
header
“ข้อมูลกำกับ” ที่ไปพร้อมคำตอบ เช่น Content-Type บอกว่า body เป็นชนิดอะไร · Location บอกว่าของที่เพิ่งสร้างอยู่ที่ไหน
headers already written (บางภาษาเรียก response committed)
“คำตอบถูกส่งออกไปแล้ว แก้ไม่ได้อีก” · ข้อความที่ Gin พิมพ์จริง ในหัวข้อ 6 คือ Headers were already written — ส่วนคำว่า response committed เป็นคำเรียกเรื่องเดียวกันในโลก Java/Spring (isCommitted) ฝั่ง Node/Express เรียก res.headersSent · เวลาค้นให้ยกข้อความที่เห็นใน log ของภาษาตัวเองไปค้นตรง ๆ จะเจอเร็วกว่าค้นว่า “status ไม่เปลี่ยน”
superfluous response.WriteHeader call
log ของ net/http ที่แปลว่า “สั่ง status ซ้ำ หรือสั่งช้าไปแล้ว” · ข้อดีคือมันบอกชื่อฟังก์ชันและเลขบรรทัดมาให้เสร็จ เห็นเมื่อไหร่ตามไปที่บรรทัดนั้นได้เลย

10 · พิสูจน์ด้วยเทส — status คือสิ่งที่เทสง่ายที่สุดในทั้ง API

ข้อดีของ status code คือมันเป็น int ตัวเดียว จะ assert (เขียนเงื่อนไขให้เทสตรวจว่าค่าที่ได้ตรงกับที่คาดไว้ไหม) ก็ง่ายที่สุดในบรรดาทุกอย่างของ API ใช้ httptest ที่ติดมากับ Go ได้เลย ไม่ต้องเปิด server จริง โครงเป็น table-driven test แบบเดียวกับ บทเรียน 16:

สามชิ้นที่เจอครั้งแรก: NewRouter() = ฟังก์ชันของเราเอง ที่คืน *http.ServeMux ที่ลงทะเบียน route ไว้หมดแล้ว (ในภารกิจข้างล่างต้องเขียนเอง) · httptest.NewRequest = ปั้น request ปลอมขึ้นมาหนึ่งอัน · httptest.NewRecorder() = กระดาษเปล่าที่รับคำตอบแทน client จริง แล้วอ่านผลได้ที่ w.Code กับ w.Body
เรียก router.ServeHTTP(w, req) = สั่งให้ router ทำงานตรง ๆ โดยไม่ต้องเปิดพอร์ตจริงเลย เทสจึงเร็วและไม่ชนกันเอง

func TestUserStatus(t *testing.T) {
    tests := []struct {
        name     string
        path     string
        wantCode int
    }{
        {"เจอ user -> 200", "/users/1", http.StatusOK},
        {"ไม่เจอ user -> 404", "/users/999", http.StatusNotFound},
    }
    router := NewRouter()
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            w := httptest.NewRecorder()                // recorder ใหม่ทุกเคส
            router.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
            if w.Code != tc.wantCode {
                t.Fatalf("อยากได้ %d แต่ได้ %d  body=%s", tc.wantCode, w.Code, w.Body.String())
            }
        })
    }
}

✅ handler ที่ตอบ 404 ตอนไม่เจอ

$ go test -v -count=1 ./prove/
=== RUN   TestUserStatus
=== RUN   TestUserStatus/เจอ_user_->_200
=== RUN   TestUserStatus/ไม่เจอ_user_->_404
--- PASS: TestUserStatus (0.00s)
    --- PASS: TestUserStatus/เจอ_user_->_200 (0.00s)
    --- PASS: TestUserStatus/ไม่เจอ_user_->_404 (0.00s)
PASS
ok  	check34/prove	0.452s

❌ handler เดิม แต่ตอบ 200 พร้อม error body

$ BUG=1 go test -v -count=1 ./prove/
=== RUN   TestUserStatus
=== RUN   TestUserStatus/เจอ_user_->_200
=== RUN   TestUserStatus/ไม่เจอ_user_->_404
    app_test.go:24: อยากได้ 404 แต่ได้ 200  body={"error":"user not found"}
--- FAIL: TestUserStatus (0.00s)
    --- PASS: TestUserStatus/เจอ_user_->_200 (0.00s)
    --- FAIL: TestUserStatus/ไม่เจอ_user_->_404 (0.00s)
FAIL
FAIL	check34/prove	0.236s
FAIL

นี่คือเทสที่จับกับดักของหัวข้อ 7 ได้ก่อนขึ้น production — และสังเกตว่าเคส "เจอ user" ยัง PASS อยู่ทั้งสองฝั่ง ซึ่งตรงกับความเป็นจริง: bug แบบนี้ไม่เคยทำให้ happy path พัง มันซ่อนอยู่ในเส้นทาง error เสมอ

⚠️ กับดักในตัวเทสเอง — httptest.NewRecorder() มีค่า w.Code เริ่มต้นเป็น 200 ตั้งแต่ยังไม่ถูกเขียนอะไรเลย
แปลว่า assert ว่า w.Code == 200 เฉย ๆ พิสูจน์ไม่ได้ว่า handler ทำงานจริง — ต้อง assert body ควบไปด้วยเสมอ
และต้องสร้าง recorder ใหม่ทุกเคส: ใช้ตัวเดิมยิงซ้ำ w.Code จะค้างที่ครั้งแรก และ body ต่อกันเป็นพืด — เทสจะ "ผ่าน" แบบผิด ๆ

ทดสอบความเข้าใจ

1. ระบบสมัครสมาชิกตอบ 500 ทุกครั้งที่อีเมลซ้ำ — ผลเสียที่ตรงที่สุดคืออะไร?

4xx = client ต้องแก้ · 5xx = server ต้องแก้ — อีเมลซ้ำคือคำขอที่ชนกับสถานะปัจจุบัน จึงเป็น 409 Conflict ซึ่งเป็น 4xx การตอบ 500 จึงเท่ากับรับผิดแทน client ทำให้กราฟ 5xx พุ่ง alert ดัง คน on-call ตื่นมาไล่ log แล้วพบว่าไม่มีอะไรพังเลย
ที่แพงกว่านั้นคือ ครั้งต่อไปที่ 5xx พุ่งเพราะ DB ล่มจริง จะไม่มีใครเชื่อกราฟนั้นอีก
ตัวเลือกที่ 1 ผิด — HTTP ไม่ได้ห้าม retry 500 (ตรงกันข้าม 5xx คือกลุ่มที่ retry แล้วมีโอกาสสำเร็จมากกว่า 4xx ด้วยซ้ำ) · ตัวเลือกที่ 2 ผิดเพราะ 500 ไม่ใช่คำตอบที่ถูก cache โดยปริยาย · ตัวเลือกที่ 4 ผิดชัดเจน เราเขียน w.WriteHeader(http.StatusInternalServerError) เองได้ตลอด

2. ผู้ใช้ล็อกอินสำเร็จแล้วในฐานะสมาชิกธรรมดา กด DELETE /users/1 ที่ให้เฉพาะแอดมิน ควรตอบอะไร?

401 = ยังไม่รู้ว่าคุณเป็นใคร · 403 = รู้แล้ว แต่คุณทำไม่ได้ เคสนี้ระบบอ่าน token ออกและรู้ว่าเป็นสมาชิกคนไหนแล้ว จึงเป็น 403
ที่สำคัญคือมันเปลี่ยนพฤติกรรม frontend: เจอ 401 ให้เด้งไปหน้า login · เจอ 403 ห้ามเด้ง เพราะล็อกอินอยู่แล้ว จะกลายเป็นลูปที่ผู้ใช้ออกไม่ได้
ผมรันจริงได้ทั้งสองเคส: ไม่ส่ง token → 401 Unauthorized พร้อม WWW-Authenticate: Bearer realm="api" · ส่ง token ที่ไม่ใช่แอดมิน → 403 Forbidden
ตัวเลือกที่ 1 ผิดเพราะ token ใช้ได้ปกติ แค่สิทธิ์ไม่ถึง · ตัวเลือกที่ 3 ผิดเพราะ 400 ไว้ใช้กับคำขอที่รูปแบบผิด ไม่ใช่เรื่องสิทธิ์ · ตัวเลือกที่ 4 ผิดหนักสุด เพราะ 204 เป็น 2xx = บอก client ว่าลบสำเร็จแล้ว

3. server ตอบ 200 พร้อม body {"error":"user not found"} — client ที่เช็ค if resp.StatusCode >= 400 จะเป็นยังไง?

ผลรันจริงของ client ตัวเดียวกันยิงใส่ server สองตัวที่ body เหมือนกันเป๊ะ:
ตอบ 200 → err = <nil> และ โหลดสำเร็จ: id="" name=""
ตอบ 404 → err = api error: 404 Not Found
สิ่งเดียวที่ต่างกันคือตัวเลข และมันตัดสินทุกอย่าง
ตัวเลือกที่ 1 กับ 3 ผิดด้วยเหตุผลเดียวกัน: {"error":"user not found"} เป็น JSON ที่ถูกไวยากรณ์ทุกประการ Unmarshal จึงสำเร็จ แค่ไม่มี field ให้เติม ก็ปล่อยเป็น zero value ไป ไม่ error และไม่ panic · ตัวเลือกที่ 2 ผิดเพราะไม่มีมาตรฐานไหนบอกว่า field ชื่อ error มีความหมายพิเศษ — มันเป็นชื่อที่เราตั้งเองล้วน ๆ
ผลตามมาคือ monitoring ก็มองไม่เห็น: นับ class ได้ 2xx → นับเป็น error? false

4. handler สั่ง w.WriteHeader(http.StatusNoContent) แล้วเขียน {"message":"deleted"} ต่อ — client ได้อะไร?

ผลรันจริง:
[server] w.Write -> n=0 err=http: request method or response status code does not allow body
[client] status="204 No Content" ContentLength=0
Go บอกเราแล้ว แต่บอกผ่าน return value ที่เราไม่ได้รับ — fmt.Fprintln(w, ...) และ json.NewEncoder(w).Encode(v) ไม่ได้เช็ค error ตัวนั้นให้ ผลคือ body หายเงียบ ไม่มี log ไม่มี panic
ตัวเลือกที่ 2 ผิดเพราะ 204 ห้ามมี body ระดับ protocol · ตัวเลือกที่ 3 ผิดเพราะ status เขียนได้ครั้งเดียว การเขียน body ทีหลังไม่เปลี่ยนตัวเลข (ตรงกันข้าม ถ้าเขียน body ก่อนแล้วค่อย WriteHeader(400) จะได้ 200 พร้อม log superfluous response.WriteHeader call) · ตัวเลือกที่ 4 ผิดเพราะไม่มี panic เลย
สรุปสั้น: อยากส่งข้อความกลับ ใช้ 200 · เลือก 204 เมื่อจะไม่ส่งอะไรจริง ๆ

🛠️ ภารกิจ — ทำ API ที่ตอบเก้ารหัสให้ครบ แล้วจับตัวเองที่ตอบ 200 ผิด

ใช้ net/http ล้วน ๆ ไม่ต้องลง dependency อะไรเลย เริ่มจากศูนย์แบบนี้:

mkdir status-lab && cd status-lab
go mod init statuslab

แล้วสร้างไฟล์ main.go (ขึ้นต้นด้วย package main) ที่มี func main() สร้าง mux := http.NewServeMux() · ลงทะเบียน route · ปิดท้ายด้วย http.ListenAndServe(":8080", mux)
ทุกคำสั่งข้างล่างนี้รันจากในโฟลเดอร์ status-lab: เปิด server ด้วย go run . ไว้เทอร์มินัลหนึ่ง แล้วเปิดอีกเทอร์มินัลไว้ยิง curl ไปที่ http://127.0.0.1:8080/...

  1. สร้าง resource เดียวก่อน — POST /users กับ DELETE /users/{id} เก็บข้อมูลไว้ใน map ในหน่วยความจำก็พอ
    ยังไม่ต้องมี DB ยังไม่ต้องมี framework
  2. ไล่ตอบให้ครบเก้ารหัส ตามตารางในหัวข้อ 3: 201 (สร้างสำเร็จ + Location) · 409 (อีเมลซ้ำ) · 400 (JSON พัง) · 401 (ไม่มี token) · 403 (token ไม่ใช่แอดมิน) · 204 (ลบสำเร็จ) · 404 (ลบซ้ำ) · 200 (GET ที่เจอ) · 500 (จำลอง dependency ล่ม)
    ใช้ค่าคงที่ http.Status... เท่านั้น ห้ามพิมพ์เลขดิบสักตัว
  3. ยิงด้วย curl -i ทุกเคส แล้วเก็บบรรทัดแรกของแต่ละอันไว้
    ยิง method ที่ไม่ได้ลงทะเบียนด้วย เช่น curl -i -X PATCH http://127.0.0.1:8080/users/1 — ได้อะไร และใครเป็นคนตอบ (ใบ้: บรรทัด Allow: จะบอกเอง)
  4. ลอง 204 ให้พัง — หลัง WriteHeader(204) ให้เขียน n, err := w.Write(...) แล้ว log ค่า err ออกมาดู
    เทียบกับตอนใช้ json.NewEncoder(w).Encode(v) — ทำไมแบบหลังถึงเงียบสนิท
  5. สลับลำดับให้พัง — ย้าย w.Header().Set("Content-Type", ...) ไปไว้หลัง WriteHeader แล้วดู Content-Type ที่ client ได้จริง
    จากนั้นลองเขียน body ก่อนแล้วค่อย WriteHeader(400) — ดู log ฝั่ง server ให้เจอคำว่า superfluous
  6. ทำกับดักหัวข้อ 7 ให้เกิดกับตัวเอง — เปลี่ยนเคส "ไม่เจอ user" ให้ตอบ 200 พร้อม {"error":"user not found"} แล้วเขียน client สั้น ๆ ที่เช็คแค่ resp.StatusCode >= 400 ยิงใส่
    พิมพ์ค่าที่ได้ออกมาให้เห็นกับตาว่า err เป็น nil
  7. เขียนเทสพิสูจน์ — สร้าง main_test.go ไว้ในโฟลเดอร์เดียวกัน เขียน table-driven test ด้วย httptest.NewRecorder() ครอบคลุมทั้งเก้ารหัส แต่ละเคส assert ทั้ง w.Code และ body แล้วรันด้วย go test -v -count=1 ./...
    เงื่อนไขที่ถือว่าผ่านภารกิจ: ย้อนข้อ 6 กลับมา (ตอบ 200) แล้วเทสต้องแดง และแก้กลับเป็น 404 แล้วต้องเขียว
    ถ้าตอบ 200 แล้วเทสยังเขียวอยู่ แปลว่าเทสยังไม่ได้เทสอะไร (เช็คว่าสร้าง recorder ใหม่ทุกเคสหรือยัง)
  8. ท้าทาย: ลองเรียก w.WriteHeader สองครั้งในเส้นทางเดียว แล้วตอบสามข้อ — client ได้เลขไหน · ฝั่ง server มี log ไหม · โปรแกรมตายหรือเปล่า

ถ้าลง route ไว้แค่ "DELETE /users/{id}" แล้วยิง PATCH เข้าไป คุณจะไม่ได้เข้า handler ตัวเองเลย — ServeMux ตอบให้ก่อน ด้วย 405 Method Not Allowed พร้อม Allow: DELETE
ข้างล่างคือผลรันของพฤติกรรมเดียวกัน แต่บน route ตัวอย่างที่ลงไว้แค่ "GET /hello" (ดูบรรทัด Allow เทียบกัน):

$ curl -s -i -X POST http://127.0.0.1:8765/hello
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff

Method Not Allowed

สองจุดที่ควรสังเกต: 1) ลงไว้แค่ GET แต่ Allow ขึ้น GET, HEAD เพราะ Go แถม HEAD ให้เอง · 2) body เป็น text/plain ไม่ใช่ JSON — ถ้า client ของคุณ Unmarshal ทุก response โดยไม่ดู status ก่อน มันจะพังตรงนี้
และถ้าย้ายโค้ดนี้ไปอยู่บน Gin เมื่อไหร่ เคสเดียวกันจะได้ 404 แทน 405 เพราะ HandleMethodNotAllowed ปิดไว้เป็น default (หัวข้อ 8)

💬 ข้อ 6 กับข้อ 7 คือสองข้อที่ผมอยากเห็นผลที่สุด — เพราะวันที่คุณเห็น err = <nil> ออกมาจากคำตอบที่มีคำว่า "error" อยู่ในนั้นด้วยตาตัวเอง คุณจะไม่มีวันเขียน c.JSON(200, gin.H{"error": ...}) อีกเลยตลอดชีวิต
ถ้ารันแล้วได้ผลไม่ตรงกับในบท ส่ง output มาได้เลย — โดยเฉพาะถ้าคุณใช้ Go คนละเวอร์ชัน เพราะเรื่อง 307/301 กับ 405 เปลี่ยนไปตามเวอร์ชันของ ServeMux จริง ๆ
บทถัดไป: ข้อมูลที่ client ส่งมามีสามช่องทาง — query, body, header — และการเลือกช่องผิดทำให้ได้ค่าว่างแบบเงียบ ๆ ได้เหมือนกัน

อ้างอิง