โหมดมืด
บทที่ 3 — Versioning และ Error Handling
หลังจบบท คุณจะ:
- ตัดสินใจ versioning strategy ที่เหมาะกับทีม
- ออกแบบ error response ที่ developer ใช้งานง่าย
- ใช้ Problem Details (RFC 9457 — supersedes RFC 7807)
- จัดการ breaking change + deprecation
- รู้ว่าเมื่อไหร่ควร break version
RFC = เอกสารมาตรฐานสากลของอินเทอร์เน็ต เลขคือหมายเลขเอกสาร ไม่ต้องจำ (เช่น RFC 9457 = มาตรฐานหน้าตาของ error response ฉบับใหม่ที่มาแทน RFC 7807)
Part 1: Versioning
1. ทำไมต้อง Versioning
เริ่มมี API → 6 เดือนผ่านไป → ต้องเปลี่ยน response format
Before:
GET /users/1 → { "name": "Anna", "age": 25 }
After:
GET /users/1 → { "firstName": "Anna", "lastName": "B.", "age": 25 }ทุก client เก่า → break 😱
→ Version = "v1 อยู่ตามเดิม + v2 มี format ใหม่"
2. Breaking vs Non-breaking Change
✅ Non-breaking (safe)
- เพิ่ม field ใน response
- เพิ่ม endpoint ใหม่
- เพิ่ม optional query parameter
- เพิ่ม optional field ใน request body
- ขยาย enum (เพิ่ม value)
- ลด restriction (เพิ่มความยาว max ของ field)
❌ Breaking (need version)
- ลบ / rename field
- เปลี่ยน type ของ field
- ลบ endpoint
- ลบ enum value
- เปลี่ยน status code semantics
- เปลี่ยน auth method
- เพิ่ม required field ใน request
กฎทอง — Robustness Principle
"Be conservative in what you send, be liberal in what you accept" — Jon Postel
แปลไทย: "ตอนส่งให้ระมัดระวัง ตอนรับให้ใจกว้าง" — ส่งออกไปให้เป๊ะตามมาตรฐาน แต่ตอนรับเข้ามาให้ยืดหยุ่น เผื่อ data มีส่วนเกินที่เราไม่รู้จัก
📝 หมายเหตุภาษา: ใน context นี้ "conservative" = "ระวัง/strict" (ส่งให้เป๊ะ ไม่เกิน spec) และ "liberal" = "ใจกว้าง/lenient" (รับได้แม้มี field เกิน) — ไม่ใช่ความหมายทางการเมือง
- Server: เพิ่มอย่างเดียว ลบช้า ๆ
- Client: ignore field ที่ไม่รู้จัก
3. Versioning Strategies
A. URI Versioning ⭐ (ส่วนใหญ่ใช้)
/api/v1/users
/api/v2/usersข้อดี:
- ✅ ชัดเจน + เห็นใน URL
- ✅ Cache-friendly
- ✅ Easy to route (nginx route v1 → server A, v2 → server B)
ข้อเสีย:
- ❌ "violation of REST" (URI ควรเป็น resource — ไม่ใช่ version)
- ❌ Client ต้อง update URL ทุกที่
ในงานจริง — URI versioning ง่ายและชัดที่สุด — REST puritan แพ้
B. Header Versioning
http
GET /api/users
Accept: application/vnd.myapp.v2+json
vnd= "vendor" — ส่วนของ media type ที่บริษัทเอง (vendor) ตั้งชื่อได้ตามมาตรฐาน IANA (เช่นvnd.myapp,vnd.github) ตามด้วย+jsonเพื่อบอกว่า encode เป็น JSON
ข้อดี:
- ✅ URL clean
- ✅ Same resource — different representation
ข้อเสีย:
- ❌ Hidden — debug ยาก (เปิด browser ดูไม่เห็น version)
- ❌ Cache + routing ลำบาก
- ❌ Test ผ่าน curl ยุ่งขึ้น
C. Query Parameter
/api/users?version=2ข้อดี:
- ✅ Easy
ข้อเสีย:
- ❌ Semantic ไม่ถูก (param ควรเป็น filter/sort)
- ❌ Cache key เปลี่ยน
D. Content Negotiation (Media Type)
http
GET /users
Accept: application/json; version=2GitHub ใช้ pattern คล้าย ๆ:
Accept: application/vnd.github.v3+jsonเลือกแบบไหน?
ทำ public API + ทีมใหม่ → URI versioning (v1, v2)
ทำ enterprise/strict REST → Header (Accept)
ทำ internal API → URI versioningหนังสือนี้ → ใช้ URI versioning เพราะ easy + popular
4. Version Number Convention
Major Only
/v1, /v2, /v3→ เปลี่ยนเมื่อ breaking change
Major.Minor
/v1.0, /v1.1, /v2.0→ overengineering สำหรับ REST API
→ ใช้ major only + เพิ่ม field ได้ใน version เดิม
Date-based
?version=2026-05-18Stripe ใช้ pattern คล้าย ๆ — client lock version ตอนเริ่ม → API breaking ก็ไม่กระทบ
📝 หมายเหตุ Stripe จริง: Stripe ใช้ header
Stripe-Version: 2024-04-10(ไม่ใช่ query parameter ตามตัวอย่างข้างบน) ตัวอย่าง query เป็น syntax ทั่วไปที่อ่านง่าย แต่ของจริงควรใช้ header เพื่อไม่ปน semantic กับ filter/sort
5. Deprecation Strategy
ไม่มีใครจะใช้ v1 ตลอดไป — ต้อง deprecate + remove
Step 1: Announce + Sunset header
http
GET /api/v1/users
Response:
HTTP/1.1 200 OK
Deprecation: @1735689599
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: </api/v2/users>; rel="successor-version"Deprecation (อ่าน เด็ป-รี-เค-ชัน = "ประกาศเลิกใช้") + Sunset (อ่าน ซัน-เซ็ต = "พระอาทิตย์ตก" — metaphor หมายถึง "วันสุดท้ายของบริการ" เหมือนพระอาทิตย์ที่กำลังจะลับฟ้า)
มาตรฐาน:
- Sunset header ตาม RFC 8594 — รับเฉพาะ HTTP-date format (RFC 7231 / 5322) เช่น
Wed, 31 Dec 2026 23:59:59 GMTไม่ใช่ ISO 8601 (2026-12-31T23:59:59Z) - Deprecation header ตาม RFC 9745 (มี.ค. 2025) — รับเป็น HTTP-date หรือ Unix timestamp นำหน้าด้วย
@(เช่น@1735689599) — ค่าtrueแบบเก่าเป็น draft ที่ deprecate ไปแล้ว
Step 2: Documentation
- Mark v1 = deprecated ใน OpenAPI
- Email partners
- Blog post
- Migration guide
Step 3: Monitoring
- Log ทุก request ไป v1 → identify clients
- Alert ถ้า v1 traffic ไม่ลด ก่อน sunset dateStep 4: Sunset
- Return
410 Gone+ redirect doc - หรือ
301 Moved Permanentlyto v2 (ถ้า compatible)
Timeline
Day 0: Launch v2
Month 1: Add deprecation header on v1
Month 6: Email all v1 users
Month 12: Sunset v1ปรับตาม risk + customer base
6. Implementation — Spring Boot
มาดูว่า URL versioning ทำจริงใน Spring Boot ยังไง — แยก controller ต่อ version (/api/v1/..., /api/v2/...) ที่ map response ต่าง shape โดยใช้ service เดียวกันด้านล่าง ทำให้เพิ่ม v2 ได้โดยไม่กระทบ v1 ที่ client เดิมยังใช้อยู่
📝 คำที่ใช้ในตัวอย่าง (รายละเอียดเต็มดู Spring Boot book):
record= Java 16+ feature สำหรับสร้าง immutable DTO สั้น ๆ (auto-generate constructor/getter/equals)@RequiredArgsConstructor= Lombok annotation ที่ generate constructor รับfinalfield ทั้งหมด (ใช้คู่กับ dependency injection)orElseThrow(EntityNotFoundException::new)= method reference — ถ้าOptionalว่างให้ throwEntityNotFoundException(สั้นกว่า lambda() -> new EntityNotFoundException())
java
// Service ที่ V1 และ V2 ใช้ร่วมกัน — domain logic อยู่จุดเดียว
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepo;
public User getById(Long id) {
return userRepo.findById(id).orElseThrow(EntityNotFoundException::new);
}
}
// DTO mapper แยกตาม version — สร้างจาก domain User
public record UserResponseV1(Long id, String name, String email) {
public static UserResponseV1 from(User u) {
return new UserResponseV1(u.getId(), u.getName(), u.getEmail());
}
}
public record UserResponseV2(Long id, String fullName, String email, Instant createdAt) {
public static UserResponseV2 from(User u) {
return new UserResponseV2(u.getId(), u.getName(), u.getEmail(), u.getCreatedAt());
}
}java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserControllerV1 {
private final UserService userService;
@GetMapping("/{id}")
public UserResponseV1 get(@PathVariable Long id) {
return UserResponseV1.from(userService.getById(id));
}
}
@RestController
@RequestMapping("/api/v2/users")
@RequiredArgsConstructor
public class UserControllerV2 {
private final UserService userService;
@GetMapping("/{id}")
public UserResponseV2 get(@PathVariable Long id) {
return UserResponseV2.from(userService.getById(id));
}
}Pattern: service ทำงานกับ domain entity (
User) ตัวเดียวกันทั้งคู่ — controller แต่ละ version map เป็นUserResponseV*ด้วย static factoryfrom(User)ทำให้เพิ่ม version ใหม่ได้โดยไม่ต้องแตะ domain logic
Part 2: Error Handling
7. กฎทอง Error Response
✅ ต้องมี:
- HTTP status code ที่ถูก (400/401/403/404/409/422/500)
- Error code (string) — machine-readable
- Message — human-readable
- Detail — แสดงรายละเอียดที่ผิด
- Trace ID — สำหรับ debug
❌ อย่า:
- Return 200 +
{ "error": ... }— status code คือ contract - Stack trace ใน production response
- Generic "Internal Error" สำหรับ validation
- Error message ที่ help attacker (e.g. "SQL syntax error...")
8. Error Format — Examples
A. Simple
json
{
"error": "Email already exists"
}⚠️ Pros: simple
⚠️ Cons: ไม่มี code, ไม่ extensible
B. Standard Envelope
json
{
"error": {
"code": "EMAIL_EXISTS",
"message": "Email already in use",
"timestamp": "2026-05-18T10:00:00Z",
"traceId": "abc-123"
}
}C. With Field Details (Validation)
json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "field": "email", "code": "INVALID_FORMAT", "message": "must be a valid email" },
{ "field": "age", "code": "OUT_OF_RANGE", "message": "must be >= 18" }
],
"timestamp": "2026-05-18T10:00:00Z",
"traceId": "abc-123"
}
}D. Problem Details (RFC 9457 — Updated ปี 2024) ⭐ Standard
ข้อมูลใหม่ปี 2026: RFC 7807 (2016) ถูก obsoleted โดย RFC 9457 (กรกฎาคม 2024) — เพิ่ม clarifications + i18n extensions. แต่ format หลักยังเหมือนเดิม.
json
{
"type": "https://example.com/errors/email-exists",
"title": "Email Already Exists",
"status": 409,
"detail": "Email 'anna@example.com' is already registered",
"instance": "/api/v1/users",
"traceId": "abc-123"
}http
Content-Type: application/problem+jsonContent-Type: application/problem+json —
problem= error format ตาม RFC 9457,+json= ส่งเป็น JSON (ถ้าเป็น XML จะเป็นapplication/problem+xml)📝 เกี่ยวกับ
typeURI: spec แนะนำให้typeresolve ไปหน้า documentation ที่อ่านได้จริง — ถ้ายังไม่มี doc ให้ใช้ค่า default"about:blank"(อย่าทิ้ง placeholder URL ที่เปิดไม่ขึ้นใน production)
Field Definitions
| Field | คำอธิบาย |
|---|---|
type | URI ของ error type (ลิงค์ไป doc) |
title | Short human-readable summary |
status | HTTP status code (เหมือนใน response) |
detail | Specific human-readable detail |
instance | URI ของ specific occurrence |
| Custom | เพิ่ม field อะไรก็ได้ (เช่น traceId, errors) |
Adoption ปี 2026
✅ Spring Boot 3+ built-in (ProblemDetail class)
✅ ASP.NET Core
✅ FastAPI (Python)
✅ NestJS (with library)⚠️ ต้องการ source — ตรวจครั้งสุดท้าย 2026-06: บางแหล่งระบุว่า Stripe/GitHub กำลังปรับมาใช้ Problem Details — ก่อนอ้างให้ตรวจ changelog/blog ของ vendor เอง (ปัจจุบัน format error ของ Stripe ยังเป็น envelope ของตัวเอง)
ใช้กันแพร่หลายในปี 2026 — แนะนำสำหรับ API ใหม่ทุกตัว
9. Problem Details Implementation (RFC 9457 / 7807)
Problem Details คือ format error มาตรฐานที่เรียนไปก่อนหน้า — Spring Boot 3+ รองรับในตัวผ่าน ProblemDetail (class นี้รองรับทั้งสเปคเก่า RFC 7807 และเวอร์ชันใหม่ RFC 9457 เพราะ field หลักไม่ต่างกัน) ใช้ @RestControllerAdvice จับ exception แต่ละชนิดแล้วแปลงเป็น ProblemDetail ที่มี status/title/detail + custom property (เช่น traceId) รวม error handling ไว้ที่เดียว:
Spring Boot 3+ — built-in
java
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ProblemDetail handleNotFound(EntityNotFoundException e) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, e.getMessage()
);
problem.setType(URI.create("https://api.myapp.com/errors/not-found"));
problem.setTitle("Resource Not Found");
problem.setProperty("traceId", MDC.get("traceId")); // MDC (Mapped Diagnostic Context) = ที่เก็บค่าแนบ log ต่อ 1 request (SLF4J)
return problem;
}
@ExceptionHandler(EmailExistsException.class)
public ProblemDetail handleEmailExists(EmailExistsException e) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
p.setType(URI.create("https://api.myapp.com/errors/email-exists"));
p.setTitle("Email Already Exists");
return p;
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request
) {
List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> Map.of(
"field", fe.getField(),
"code", fe.getCode(),
"message", fe.getDefaultMessage()
))
.toList();
ProblemDetail p = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
p.setTitle("Validation Failed");
p.setProperty("errors", errors);
return ResponseEntity.badRequest().body(p);
}
}⚠️ ลำดับ filter สำคัญ:
MDC.get("traceId")จะได้ค่าก็ต่อเมื่อTraceFilter(ดู §13) ทำงาน ก่อน exception handler — ถ้า order filter ไม่ถูก จะได้nullตรวจ@Orderหรือ Spring Security filter chain ให้ TraceFilter อยู่ก่อนสุด
10. Error Code Convention
Format
NOUN_VERB_REASON # ที่ stable + consistent
EMAIL_ALREADY_EXISTS
USER_NOT_FOUND
INSUFFICIENT_BALANCE
RATE_LIMIT_EXCEEDED
INVALID_TOKEN
TOKEN_EXPIRED📝 ทำไมใช้ English? error code เป็น machine-readable identifier ที่ logic code ใน client ต้อง
if (code === "EMAIL_ALREADY_EXISTS")ใช้ English ทำให้ universal (ทำงานข้ามภาษา) + stable (ไม่เปลี่ยนแม้แปล UI) ส่วนข้อความที่ user เห็นใช้messageหรือ i18n key แยกต่างหาก
Use Code, Not Message
json
✅ { "code": "INSUFFICIENT_BALANCE" }
❌ if (response.message === "You don't have enough balance") { ... }→ Message สำหรับ user, code สำหรับ logic
i18n
json
{
"error": {
"code": "EMAIL_EXISTS",
"message": "Email already exists", // English fallback
"messageKey": "errors.email.exists" // for i18n on client
}
}หรือ — เก็บ message ใน client (frontend) ตาม locale, server ส่งแค่ code
11. Status Code — เลือกถูก
http
400 Bad Request - format ผิด, missing required field
401 Unauthorized - no auth / token หมดอายุ
403 Forbidden - มี auth แต่ไม่มีสิทธิ์
404 Not Found - resource ไม่มี
405 Method Not Allowed - endpoint ไม่รองรับ method
406 Not Acceptable - Accept header ไม่รองรับ
408 Request Timeout - client ช้าเกิน
409 Conflict - state ขัด (email ซ้ำ, version mismatch)
410 Gone - resource หายไป (deprecated v1)
412 Precondition Failed - If-Match ไม่ตรง
413 Payload Too Large
415 Unsupported Media Type
422 Unprocessable Entity - validation fail
425 Too Early - retry สั้นเกิน
429 Too Many Requests - rate limit
500 Internal Server Error
501 Not Implemented
502 Bad Gateway - upstream service error
503 Service Unavailable - server down/maintenance
504 Gateway Timeout400 vs 422 — ใช้ตัวไหน?
ดีเบทเก่า:
- 400 Bad Request = "request ผิด (parse ไม่ได้)" — เช่น invalid JSON
- 422 Unprocessable Content = "parse ได้ แต่ validation ผิดเชิง semantic" — เช่น email ผิด format, age < 18
แนวทางในหนังสือนี้ — ใช้ 422 เมื่อ field-level validation fail (semantic ชัดกว่า แยก parse error ออกได้) และใช้ 400 เมื่อ request ผิดรูป (JSON พัง, missing required field ที่ parse ไม่ได้):
| เคส | code |
|---|---|
| Body ไม่ใช่ JSON / parse error | 400 |
| Missing required field ที่ binder fail | 400 |
Field ครบแต่ validation rule ผิด (@Email, @Min) | 422 |
| Type mismatch ที่ Jackson แปลงไม่ได้ | 400 |
Framework default ต่างกัน — รู้ไว้:
- Spring Boot validation บน
@RequestBody(@Validfail →MethodArgumentNotValidException) → default คืน 400 (จะเปลี่ยนเป็น 422 ต้อง override ในhandleMethodArgumentNotValid) - ⚠️ Spring Boot validation บน
@RequestParam/@PathVariable(เช่น@RequestParam @Email String email→ConstraintViolationException) → default คืน 500 (ต้องเพิ่ม@ExceptionHandler(ConstraintViolationException.class)คืน 400/422) - Stripe / Twitter API → ใช้ 400 สำหรับทุก validation error
- GitHub API → ใช้ 422 สำหรับ validation
📝 คำศัพท์:
- Bean Validation = มาตรฐาน Java (JSR 380) ของ annotation ที่ใส่บน field/parameter เพื่อ validate (
@Min(0),@NotBlank)- Jackson = library ที่ Spring ใช้แปลง JSON ↔ Java object (เป็นด่านแรกก่อน Bean Validation ทำงาน — ถ้า JSON parse ไม่ผ่านที่ Jackson จะเป็น 400 ก่อนถึง validation)
- binder = ตัวที่ Spring ใช้ map request body / query param เข้า Java parameter
→ เลือกอย่างใดอย่างหนึ่ง + consistent ทั้ง API สำคัญกว่าเลือกถูก/ผิด
12. Common Error Scenarios
มาดูว่า error format มาตรฐานใช้กับสถานการณ์จริงแต่ละแบบยังไง — not found (404), validation (422 พร้อมรายละเอียดทุก field), conflict (409), auth (401/403), rate limit (429 พร้อม Retry-After) ส่วนนี้รวม response ที่ควรคืนในแต่ละเคสให้ใช้เป็นแบบ:
Resource Not Found
GET /users/9999
HTTP 404 Not Found
{
"type": ".../errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "User with id 9999 not found"
}Validation
POST /users
{ "email": "bad", "age": 5 }
HTTP 422 Unprocessable Entity
{
"type": ".../errors/validation",
"title": "Validation Failed",
"status": 422,
"errors": [
{ "field": "email", "code": "INVALID_FORMAT" },
{ "field": "age", "code": "OUT_OF_RANGE", "min": 18 }
]
}Conflict
POST /users
{ "email": "exists@x.com", ... }
HTTP 409 Conflict
{
"code": "EMAIL_EXISTS",
"message": "Email already in use",
"conflictingField": "email"
}Authorization
GET /admin/users
[no auth header]
HTTP 401 Unauthorized
{ "code": "MISSING_TOKEN", "message": "Authorization required" }
HTTP 401 Unauthorized
{ "code": "EXPIRED_TOKEN", "message": "Token expired" }
HTTP 403 Forbidden
{ "code": "INSUFFICIENT_ROLE", "message": "Admin role required" }Rate Limit
HTTP 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1747562460
{
"code": "RATE_LIMITED",
"message": "Too many requests. Retry in 60 seconds"
}13. Trace ID — Debug Critical
เมื่อ user แจ้งว่า "API error" แต่ไม่รู้ว่า request ไหน — trace ID แก้ปัญหานี้ ทุก request มี ID ที่ใส่ทั้งใน response และ log เมื่อ user ส่ง ID มา dev ก็ตามไปหา log ของ request นั้นได้ทันที ส่วนนี้แสดง filter ที่ generate/propagate trace ID:
Request:
X-Request-ID: abc-123 ← client send (optional)
→ server use หรือ generate
Response:
X-Request-ID: abc-123java
@Component
public class TraceFilter extends OncePerRequestFilter {
private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9\\-]{1,128}");
@Override
protected void doFilterInternal(...) {
String traceId = req.getHeader("X-Request-ID");
// ⚠️ sanitize ค่าจาก client ก่อนใช้ — กัน log injection (newline, control char)
if (traceId == null || !SAFE_ID.matcher(traceId).matches()) {
traceId = UUID.randomUUID().toString();
}
MDC.put("traceId", traceId); // ใส่ traceId เข้า MDC (Mapped Diagnostic Context) → ทุกบรรทัด log ของ request นี้จะมี traceId ติดไป
try {
res.setHeader("X-Request-ID", traceId);
chain.doFilter(req, res);
} finally {
MDC.clear();
}
}
}⚠️ อย่า trust header จาก client ตรง ๆ —
X-Request-IDมาจาก attacker ก็ได้ ถ้าใส่ค่ามี\nแล้วเขียนตรง ๆ ลง log จะเป็น log injection (attacker ปลอม log entry ได้) sanitize ให้รับเฉพาะ alphanumeric + dash เสมอ
ทุก log ของ Spring Boot จะมี traceId → debug ง่าย
🚀 ขั้นสูง — รอเรียนเมื่อพร้อม deploy จริง: W3C Trace Context + OpenTelemetry เป็นมาตรฐาน production สมัยใหม่ — มือใหม่อ่านเอาแนวคิดก่อน ยังไม่ต้องลงมือใช้กับ project แรก
คำอ่าน + ขยาย:
- W3C = World Wide Web Consortium (องค์กรกำหนดมาตรฐานเว็บ)
- OpenTelemetry (อ่าน "โอเพ่น-เทเลเมตรี่" บางคนเรียกย่อ "OTel" = "โอ-เทล") = framework สำหรับเก็บ trace/metric/log ข้าม service
ตัวอย่างข้างบนใช้
X-Request-IDแบบเก่าเพื่อความเข้าใจง่าย — ของจริงปี 2026 มาตรฐานคือ headertraceparent(W3C Trace Context, W3C Rec) ที่ propagate trace/span id ข้าม service ได้ และ OpenTelemetry SDK auto-instrument ให้ Spring Boot 3+ มี built-in support ผ่าน Micrometer Tracing (management.tracing.*) → traceId/spanId เข้า MDC ให้อัตโนมัติ ไม่ต้องเขียน filter เอง · เจาะลึกในหมวด observability
14. Don't Leak Sensitive Info
error message ที่ละเอียดเกินไปช่วย attacker — ชื่อตาราง, SQL, stack trace บอกใบ้โครงสร้างภายในและช่องโหว่ กฎคือ log รายละเอียดเต็มไว้ฝั่ง server แต่คืน message กลาง ๆ ให้ client (พอ debug ได้ด้วย traceId แต่ไม่ leak):
❌ "Database error: ER_DUP_ENTRY at users_email_key"
❌ "User not found: select * from users where email = 'attacker_input'"
❌ Stack trace ใน production response
✅ "Resource not found"
✅ "Service temporarily unavailable"Log full error server-side → return generic message ให้ client
15. Idempotent Error Handling
จุดที่มักลืม — idempotency ต้องครอบ "error" ด้วย ไม่ใช่แค่ success ถ้า request แรก fail ด้วย validation error การ retry ด้วย key เดิมต้องคืน error เดิม (ไม่ใช่ลองใหม่จนสำเร็จ) เพื่อให้พฤติกรรมคาดเดาได้และกัน duplicate side effect:
POST/PATCH ที่ใช้ Idempotency-Key — return same error if retry:
POST /payments
Idempotency-Key: abc-123
{ "amount": -100 } ← invalid
HTTP 422 (first time)
{ "code": "INVALID_AMOUNT" }
[client retry]
POST /payments
Idempotency-Key: abc-123
HTTP 422 (same response) ← ⭐ cachedRule ตอน implement idempotency cache
- Cache key =
Idempotency-Key+ hash(request body) — กัน client ที่ส่ง key เดิมแต่ body ต่าง (เจตนาหรือพลาด) ·hashในที่นี้ใช้ algorithm ที่ deterministic อะไรก็ได้ (เช่น SHA-256) — ไม่ต้องเป็น crypto-strength เพราะใช้เทียบความเหมือนของ body เท่านั้น ไม่ใช่กัน collision ของ attacker - ถ้า key เดิม + body ต่าง → คืน
409 Conflict(ไม่ใช่คืน response เก่า, ไม่ใช่ทำ request ใหม่) - TTL — กำหนดอายุ cache ที่สมเหตุสมผล (Stripe spec: 24 ชั่วโมง — ตรวจ status ครั้งสุดท้าย 2026-06 · Stripe Docs — Idempotent Requests)
- กัน concurrent retry — request ที่มาพร้อมกันด้วย key เดียวกัน ต้อง lock (เช่น Redis
SET NX+ ttl) ไม่งั้นจะ process ซ้ำก่อน cache จะถูก write - Error response ก็ต้อง cache — ตามตัวอย่างด้านบน retry แล้วต้องเจอ error เดิม ไม่ใช่ retry จนผ่าน
16. ⚠️ Common Pitfalls
ปิดท้ายบทด้วยสรุปกับดักเรื่อง versioning และ error handling ที่เจอบ่อย — คืน 200 พร้อม error ใน body, error format ไม่ consistent, ไม่มี version, leak stack trace ตารางจับคู่ "ผิด" กับ "ถูก" ใช้เป็น checklist ตรวจ API ก่อนปล่อย:
| ❌ | ✅ |
|---|---|
200 OK + { "error": ... } | proper 4xx/5xx status |
| Generic "Internal Error" | specific code (404, 409, 422) |
| Stack trace exposed | log internal, return safe message |
| Different format ใน endpoint ต่างกัน | global handler + consistent format |
| Magic status code (599) | standard codes only |
code: "ERR1" | descriptive EMAIL_EXISTS |
| No trace ID | always include |
| 500 for validation | 400/422 |
| 404 for forbidden | 403 (with auth) |
| Same wording in different errors | localize per case |
17. Versioning Pitfalls
| ❌ | ✅ |
|---|---|
| ไม่มี version ใน URL | /api/v1/... ตั้งแต่แรก |
| เพิ่ม required field โดยไม่ version | bump version หรือ optional |
| Drop endpoint without notice | deprecate + sunset header + email |
| Version 1.0.0, 1.0.1, ... | use major only |
| Multiple version forever | sunset old versions |
18. Checkpoint
🛠️ Checkpoint 3.1 — Setup Global Error Handler
ใน Spring Boot project:
@RestControllerAdviceที่ handle: NotFound, Validation, EmailExists, Generic- Return Problem Details (RFC 9457)
- รวม traceId
- Test ด้วย curl + ดู response
🛠️ Checkpoint 3.2 — Versioning
- API v1:
/api/v1/users/{id}→ return{ "name": "...", "age": 25 } - API v2:
/api/v2/users/{id}→ return{ "firstName": "...", "lastName": "...", "age": 25 } - Share service — split DTO
🛠️ Checkpoint 3.3 — Deprecation
ใส่ deprecation header ใน v1:
Deprecation: trueSunset: <date>Link: </api/v2/...>; rel="successor-version"
🛠️ Checkpoint 3.4 — Error Codes
สร้าง error code catalog (เอกสาร):
- 20+ error code ที่ใช้ในระบบ
- description + HTTP status + ตัวอย่าง response
19. สรุปบท
✅ Versioning จำเป็นสำหรับ breaking change — URI versioning (/v1/...) ง่ายสุด
✅ Breaking = ลบ/rename, เปลี่ยน type. Non-breaking = เพิ่ม field/optional
✅ Deprecation header + sunset date + monitoring + migration guide
✅ Error response ต้องมี: HTTP status + code + message + detail + traceId
✅ Problem Details (RFC 9457, supersedes RFC 7807) = standard ปี 2026 — application/problem+json
✅ Error code = stable + machine-readable (EMAIL_EXISTS > Error #42)
✅ Status code ตามมาตรฐาน — 400 vs 422 เลือก 1
✅ Trace ID ทุก response — debug critical
✅ อย่า leak internal — log full / return safe message
← บทที่ 2 | บทที่ 4 → Auth + Security + Rate Limit
Glossary: ../glossary.md · Style guide: ../CONTRIBUTING.md last_verified: 2026-06-03 · review report: ../REVIEW-2026-06-03.md