ออกแบบ error ให้เป็น first-class citizen — ต่อจาก Double Report กับ Logging
สองบทความก่อนเราคุยกันเรื่อง:
- Double Report — หลักการ "log หรือ return อย่างใดอย่างหนึ่ง"
- Logging ใน microservice — วิธี log ให้ถูกที่ ถูกเวลา
บทความนี้คือภาค 3 — เมื่อ log ถูกที่แล้ว — แล้ว error ควรมีหน้าตายังไง
เพราะ error ไม่ใช่แค่ err.Error() — มันคือ API contract ระหว่าง layer — และการออกแบบ error ให้ดี จะทำให้ caller จัดการได้ถูกต้องโดยไม่ต้องอ่าน string message
1. Sentinel Errors — "error ที่รู้จักกันทั่ว"
Sentinel error คือ error ที่ประกาศเป็นตัวแปรระดับ package — ใช้ errors.Is เช็ค:
// repo/errors.go
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("resource already exists")
ErrInvalidInput = errors.New("invalid input")
)
// service/user.go
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.FindByID(ctx, id)
if errors.Is(err, repo.ErrNotFound) {
return nil, ErrNotFound // map ไปเป็น error ของ service เอง
}
if err != nil {
return nil, fmt.Errorf("GetUser: %w", err)
}
return user, nil
}
ข้อดี: caller ไม่ต้อง parse string — errors.Is(err, repo.ErrNotFound) — ชัดเจน
ข้อเสีย: sentinel error กลายเป็น public API ของ package — เปลี่ยนไม่ได้โดยไม่ break caller
Sentinel vs string matching
// ❌ แย่ — parse string
if strings.Contains(err.Error(), "not found") {
// error message เปลี่ยน = โค้ดพัง
}
// ✅ ดี — sentinel
if errors.Is(err, repo.ErrNotFound) {
// message เปลี่ยนยังไงก็ไม่พัง
}
2. Custom Error Types — "error ที่พกข้อมูล"
Sentinel บอกว่า "เกิดอะไร" — แต่บางครั้ง caller ต้องการข้อมูลเพิ่ม:
// service/errors.go
type ValidationError struct {
Field string
Value any
Message string
Err error // ← เก็บ sentinel error ไว้ข้างใน (ถ้ามี)
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s=%v: %s", e.Field, e.Value, e.Message)
}
func (e *ValidationError) Unwrap() error {
return e.Err // ← ทำให้ errors.Is(e, ErrInvalidInput) ทำงานได้
}
// handler/user.go — ใช้ errors.As เพื่อดึงข้อมูล
var valErr *service.ValidationError
if errors.As(err, &valErr) {
slog.InfoContext(ctx, "validation failed",
slog.String("field", valErr.Field),
slog.Any("value", valErr.Value),
)
http.Error(w, valErr.Message, http.StatusBadRequest)
return
}
ความต่างจาก sentinel: errors.Is = "ใช่ error นี้ไหม?" — errors.As = "เป็น type นี้ไหม? + ดึงข้อมูล"
⚠️ Typed-nil interface trap: ถ้า function return
(*ValidationError)(nil)(nil pointer) แล้ว assign เข้าerrorinterface —err != nilจะเป็นtrueเพราะ interface เก็บ(type=*ValidationError, value=nil)— ไม่ใช่(nil, nil)— classic Go footgun ที่ทั้ง junior และ senior เจอบ่อย — วิธีแก้: returnnilแบบไม่มี type (return nilไม่ใช่return (*ValidationError)(nil)) — เพิ่มเติม: Go FAQ: nil error
3. Retryable Error — "error ที่บอกว่าให้ลองใหม่"
บาง error transient — ลองใหม่แล้วหาย (network timeout, connection reset, deadlock) — บาง error permanent — ลองกี่ครั้งก็ไม่หาย (not found, invalid input, permission denied)
// pkg/errors/retryable.go
type RetryableError struct {
Err error
}
func (e *RetryableError) Error() string { return e.Err.Error() }
func (e *RetryableError) Unwrap() error { return e.Err }
func Retryable(err error) error {
return &RetryableError{Err: err}
}
// service/order.go
func (s *Service) CreateOrder(ctx context.Context, req *CreateOrderReq) (*Order, error) {
err := s.payment.Charge(ctx, req)
if err != nil {
if isNetworkTimeout(err) {
return nil, Retryable(fmt.Errorf("Charge: %w", err))
}
return nil, fmt.Errorf("Charge: %w", err)
}
return &Order{ID: orderID}, nil
}
⚠️ ห้าม retry โดยไม่มี idempotency — โดยเฉพาะ payment: network timeout ไม่ได้แปลว่า payment gateway ไม่ได้รับ request — อาจ charge สำเร็จแล้วแต่ response ไม่กลับ — retry โดยไม่มี idempotency key = double charge — Stripe/Omise บังคับ
Idempotency-Keyheader ทุก request — ห้าม retry การ charge โดยไม่มี key เด็ดขาด
// handler — retry แบบ loop + backoff + jitter + max attempts
// (ไม่ใช่ recursion — stack overflow ถ้า error เกิดซ้ำ)
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
const maxRetries = 3
baseDelay := 100 * time.Millisecond
for attempt := 0; attempt <= maxRetries; attempt++ {
order, err := h.svc.CreateOrder(r.Context(), req) // (*Order, error)
if err == nil {
json.NewEncoder(w).Encode(order)
return
}
var retryable *RetryableError
if !errors.As(err, &retryable) || attempt == maxRetries {
slog.ErrorContext(r.Context(), "create order failed",
slog.String("error", err.Error()),
slog.Int("attempt", attempt),
)
writeError(w, err) // ← ใช้ error code + status code
return
}
// exponential backoff + jitter — ไม่ retry ทันที
delay := baseDelay * (1 << attempt) // 100ms, 200ms, 400ms
jitter := time.Duration(rand.Int63n(int64(delay / 2)))
slog.WarnContext(r.Context(), "retrying",
slog.Int("attempt", attempt+1),
slog.Int("max_retries", maxRetries),
slog.Duration("delay", delay+jitter),
)
select {
case <-time.After(delay + jitter):
case <-r.Context().Done():
return // client disconnected — ไม่ต้อง retry
}
}
}
⚠️ Sync retry ใน handler = บล็อก goroutine: ที่ traffic สูง retry loop จะผูก HTTP handler thread ไว้เป็นวินาที — เสี่ยง connection exhaustion + client-side timeout — สำหรับ operation สำคัญที่ retry ยาว ควรพิจารณา async retry queue (เช่น NATS, Redis streams) แทนการ retry ใน handler โดยตรง — ตัวอย่างด้านบนเหมาะกับ retry สั้น (< 1 วิ) และ traffic ปานกลาง
💡 handler ต้อง integrate กับ idempotency store: retry loop อย่างเดียวไม่พอ — ต้อง wrap ด้วย
idempotencyStore.Execute(...)เพื่อกัน double-submit จาก client:
// handler — retry loop + idempotency store ทำงานร่วมกัน
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
idempotencyKey := r.Header.Get("Idempotency-Key")
if idempotencyKey == "" {
writeError(w, &AppError{Code: ErrCodeInvalidInput, Message: "missing idempotency key"})
return
}
order, err := h.idempotency.Execute(r.Context(), idempotencyKey, 5*time.Minute, func() (*Order, error) {
// retry loop — ทำงานเฉพาะตอนได้ lock จาก idempotency store เท่านั้น
var lastErr error
for attempt := 0; attempt <= 3; attempt++ {
order, err := h.svc.CreateOrder(r.Context(), req)
if err == nil { return order, nil }
lastErr = err // ← จำ error ไว้ — loop จบแล้ว err ใน scope นี้หาย
var retryable *RetryableError
if !errors.As(err, &retryable) { return nil, err }
// backoff + jitter...
}
return nil, fmt.Errorf("max retries exceeded: %w", lastErr) // ← preserve retryable wrap
})
if err != nil {
writeError(w, err)
return
}
json.NewEncoder(w).Encode(order)
}
4. Error → HTTP Status Code — map แบบ declarative
Handler ไม่ควรมี switch-case ยาว ๆ — ใช้ helper:
// handler/errors.go
func httpStatus(err error) int {
switch {
case errors.Is(err, repo.ErrNotFound):
return http.StatusNotFound
case errors.Is(err, repo.ErrConflict):
return http.StatusConflict
case errors.Is(err, repo.ErrInvalidInput):
return http.StatusBadRequest
case errors.Is(err, ErrUnauthorized):
return http.StatusUnauthorized
default:
return http.StatusInternalServerError
}
}
// handler/user.go
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := h.svc.GetUser(r.Context(), id)
if err != nil {
slog.ErrorContext(r.Context(), "failed to get user",
slog.String("error", err.Error()), // ← log ใส่ internal details ได้
)
// ❌ อย่าส่ง err.Error() กลับไป client — leak internal details (SQL, stack, paths)
status := httpStatus(err)
http.Error(w, http.StatusText(status), status) // ← "Not Found" ไม่ใช่ "FindByID: pq: relation..."
return
}
json.NewEncoder(w).Encode(user)
}
ข้อดี: map error → status แบบรวมศูนย์ — เพิ่ม error type ใหม่แค่เพิ่ม case ใน httpStatus()
Advanced — error ที่บอก status code เอง
type HTTPError interface {
error
HTTPStatus() int
}
func (e *ValidationError) HTTPStatus() int { return http.StatusBadRequest }
func (e *AuthError) HTTPStatus() int { return http.StatusUnauthorized }
func httpStatus(err error) int {
var httpErr HTTPError
if errors.As(err, &httpErr) {
return httpErr.HTTPStatus()
}
return http.StatusInternalServerError
}
5. Error กับ Metric — วัดว่าระบบสุขภาพดีไหม
log อย่างเดียวไม่พอ — ต้องมี metric:
// middleware/metrics.go
func MetricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
duration := time.Since(start).Seconds()
status := responseStatus(w)
// ✅ normalize path — /users/123 → /users/{id} — กัน cardinality ระเบิด
route := routePattern(r) // เช่น chi.RouteContext(r.Context()).RoutePattern()
requestDuration.Record(r.Context(), duration,
attribute.String("method", r.Method),
attribute.String("route", route), // ← route template
attribute.Int("status", status),
)
requestCounter.Add(r.Context(), 1,
attribute.String("method", r.Method),
attribute.String("route", route),
attribute.Int("status", status),
)
})
}
⚠️ ห้ามใช้
r.URL.Pathเป็น label — ถ้า path มี dynamic segment (/users/123,/orders/456) — แต่ละ ID คือ timeseries ใหม่ → cardinality ระเบิด Prometheus — ต้อง normalize เป็น route template (/users/{id}) — chi/gin/echo ทุกตัวมีRoutePattern()ให้
metric + trace + log — อย่าแยกกัน
// error log ต้องมี trace_id + span_id — ต่อกับ OTel ได้
span := trace.SpanFromContext(r.Context())
slog.ErrorContext(r.Context(), "create order failed",
slog.String("trace_id", span.SpanContext().TraceID().String()),
slog.String("span_id", span.SpanContext().SpanID().String()),
slog.String("error", err.Error()),
)
circuit breaker — อย่า retry ใส่ downstream ที่กำลังป่วย
ถ้า payment gateway ล่ม — retry ทุกรอบมีแต่จะซ้ำเติม:
import "github.com/sony/gobreaker/v2"
var cb = gobreaker.NewCircuitBreaker[any](gobreaker.Settings{
Name: "payment-gateway",
Timeout: 30 * time.Second, // เปิด circuit นานสุด 30 วิ
})
func (s *Service) Charge(ctx context.Context, req *ChargeReq) error {
_, err := cb.Execute(func() (any, error) {
return nil, s.gateway.Charge(ctx, req)
})
if errors.Is(err, gobreaker.ErrOpenState) {
return fmt.Errorf("Charge: circuit open — gateway is down")
}
return err
}
Error Budget / SLO — ทำไมต้องมี
SLO: 99.9% ของ requests ต้องสำเร็จภายใน 1 เดือน
Error Budget: 0.1% = ~43 นาที downtime/เดือน
ถ้า error rate สูงเกิน error budget → freeze feature, focus on stability
metric + alert:
// alert rule (Prometheus):
sum(rate(requests_total{status=~"5.."}[5m]))
/ sum(rate(requests_total[5m])) > 0.001
→ pager ดัง → on-call ดู
6. ภาพรวม — error flow จาก repo ถึง user
user → handler → service → repo → database
↑ error เกิดที่นี่
สิ่งที่ควรเกิดขึ้น:
1. repo: return fmt.Errorf("FindByID: %w", err) → wrap, ไม่ log
2. service: ตัดสินว่า error คืออะไร:
errors.Is(err, sql.ErrNoRows) → return ErrNotFound
errors.Is(err, timeout) → return Retryable(err)
3. handler:
errors.As(err, &valErr) → 400 Bad Request
errors.As(err, &retryable) → retry
errors.Is(err, ErrNotFound) → 404
else → 500 + log + alert
4. metric: requestCounter{status=500} → check SLO
5. alert: error_rate > 0.1% → pager → on-call
Request ID — เชื่อม error กับ support ticket
user แจ้งปัญหา: "ผมกดสั่งซื้อแล้ว error" — ops จะ grep log ด้วยอะไร?
// middleware — generate request_id ให้ทุก request
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = uuid.New().String()
}
w.Header().Set("X-Request-ID", requestID)
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
ทุก log ควรมี request_id:
slog.ErrorContext(ctx, "payment failed",
slog.String("request_id", requestID),
slog.String("trace_id", traceID),
slog.String("error", err.Error()),
)
user ส่ง X-Request-ID: abc123 → ops grep abc123 → เจอทุก log, metric, trace ที่เกี่ยวข้อง
💡 request_id ≠ trace_id — request_id เกิดที่ edge (client/load balancer) — trace_id เกิดที่ tracing system — request_id ใช้ grep log แบบง่าย — trace_id ใช้ดู flow ข้าม service — ควรมีทั้งคู่
Error Codes — API contract ที่ machine อ่านได้
HTTP status อย่างเดียวไม่พอ — client (mobile app, partner API) ต้องรู้ว่า error อะไรแน่:
// pkg/errors/codes.go
type ErrorCode string
const (
ErrCodeNotFound ErrorCode = "ERR_USER_NOT_FOUND"
ErrCodeDuplicate ErrorCode = "ERR_DUPLICATE_EMAIL"
ErrCodeInvalidInput ErrorCode = "ERR_INVALID_INPUT"
ErrCodeUnauthorized ErrorCode = "ERR_UNAUTHORIZED"
ErrCodeInternal ErrorCode = "ERR_INTERNAL"
)
// เข้ากับ custom error type
type AppError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Err error `json:"-"` // ไม่ expose internal details
}
func (e *AppError) Error() string { return fmt.Sprintf("%s: %s", e.Code, e.Message) }
func (e *AppError) Unwrap() error { return e.Err }
// handler
func writeError(w http.ResponseWriter, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpStatus(err))
json.NewEncoder(w).Encode(map[string]string{
"code": string(appErr.Code),
"message": appErr.Message,
})
return
}
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
client จะได้:
{"code": "ERR_USER_NOT_FOUND", "message": "user with id 42 not found"}
mobile app ใช้ code switch แสดงข้อความภาษาไทย — partner API ใช้ code automate retry logic — ไม่ต้อง parse string
⚠️ User enumeration attack (OWASP): ห้ามใช้ error code granular สำหรับ auth-sensitive endpoint — ถ้า login ใช้
ERR_USER_NOT_FOUNDกับERR_INVALID_PASSWORDแยกกัน — attacker ยิง email ทีละตัว ดู response ก็รู้ว่า email ไหนมีในระบบ — สำหรับ login/password reset/register ต้อง return error code เดียวกัน (ERR_INVALID_LOGIN) ไม่ว่าจะ user not found หรือ wrong password — ส่วน resource endpoint ทั่วไป (get product, update profile) ใช้ granular code ได้ตามปกติ
idempotency — อย่าปล่อยให้ client double-submit
ส่ง Idempotency-Key ไปที่ payment gateway — แต่ถ้า client เอง retry เรียก CreateOrder ซ้ำ (double-click, network flake ฝั่ง frontend) — service ต้องป้องกันเอง:
// pkg/idempotency/store.go
type Store struct {
redis *redis.Client
}
func (s *Store) Execute(ctx context.Context, key string, ttl time.Duration, fn func() (*Order, error)) (*Order, error) {
// SETNX — atomic lock กัน race condition
lockTTL := 1 * time.Minute // lock สั้น แต่ต้องยาวกว่า timeout ของ downstream เสมอ
// ถ้า s.payment.Charge timeout=15s → lock ควร ≥30s
resultTTL := ttl // result เก็บนานกว่า — 5-15 นาที
locked, err := s.redis.SetNX(ctx, key, "processing", lockTTL).Result()
if err != nil {
return nil, fmt.Errorf("idempotency store unavailable: %w", err)
}
if !locked {
// มีอีก request กำลังประมวลผล key นี้ — poll รอ result
for i := 0; i < 10; i++ {
select {
case <-time.After(100 * time.Millisecond):
case <-ctx.Done():
return nil, ctx.Err()
}
cached, err := s.redis.Get(ctx, key).Result()
if err == redis.Nil { continue }
if err != nil { return nil, fmt.Errorf("idempotency: %w", err) }
if cached == "processing" { continue } // ยังไม่เสร็จ
if cached == "failed-ambiguous" {
return nil, fmt.Errorf("idempotency: previous attempt failed — check with gateway")
}
var order Order
json.Unmarshal([]byte(cached), &order)
return &order, nil
}
return nil, fmt.Errorf("idempotency: timeout waiting for concurrent request")
}
// ได้ lock — รัน fn จริง
order, err := fn()
if err != nil {
var retryable *RetryableError
if errors.As(err, &retryable) { // reuse type เดียวกับ retry loop — ไม่สร้างเงื่อนไขคู่ขนาน
s.redis.Set(ctx, key, "failed-ambiguous", resultTTL)
return nil, fmt.Errorf("idempotency: ambiguous failure — check with gateway: %w", err)
}
s.redis.Del(ctx, key) // non-retryable — safe, error เกิดก่อนถึง gateway
return nil, err
}
data, _ := json.Marshal(order)
s.redis.Set(ctx, key, data, resultTTL)
return order, nil
💡 Reconciliation job:
"failed-ambiguous"ไม่ใช่คำตอบสุดท้าย — ระบบ payment จริงต้องมี background job ที่ query transaction status จาก gateway (Stripe/Omise มี endpoint เช็ค charge status ด้วย idempotency key) แล้วอัปเดต state เป็น success/failed จริง — ไม่ใช่ปล่อยให้เห็น error ค้างจน TTL หมด
สรุป — เช็คลิสต์
✅ Sentinel errors สำหรับ "รู้จักกันทั่ว" (ErrNotFound, ErrConflict)
✅ Custom error types เมื่อต้องการพกข้อมูล — พร้อม Unwrap()
✅ ⚠️ typed-nil interface trap — return nil แบบไม่มี type
✅ Retryable error + idempotency key + loop/backoff/jitter + max attempts
✅ Circuit breaker — อย่า retry ใส่ downstream ที่กำลังป่วย
✅ httpStatus() — map error → status แบบรวมศูนย์
✅ ห้าม leak err.Error() กลับ client — ใช้ http.StatusText + error code
✅ Metric — cardinality-safe (route template, ไม่ใช้ raw path)
✅ Metric + trace + log ต้อง correlate กัน (trace_id + span_id)
✅ Request ID — grep ได้ตอน user แจ้งปัญหา
✅ Error Codes — machine-readable สำหรับ API contract
✅ Alert — error budget หมด → pager
📚 อ่านต่อ:
Top comments (0)