DEV Community

Gophernment
Gophernment

Posted on

ออกแบบ error ให้เป็น first-class citizen — ต่อจาก Double Report กับ Logging

ออกแบบ 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
}
Enter fullscreen mode Exit fullscreen mode

ข้อดี: 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 เปลี่ยนยังไงก็ไม่พัง
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

ความต่างจาก sentinel: errors.Is = "ใช่ error นี้ไหม?" — errors.As = "เป็น type นี้ไหม? + ดึงข้อมูล"

⚠️ Typed-nil interface trap: ถ้า function return (*ValidationError)(nil) (nil pointer) แล้ว assign เข้า error interface — err != nil จะเป็น true เพราะ interface เก็บ (type=*ValidationError, value=nil) — ไม่ใช่ (nil, nil) — classic Go footgun ที่ทั้ง junior และ senior เจอบ่อย — วิธีแก้: return nil แบบไม่มี 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
}
Enter fullscreen mode Exit fullscreen mode

⚠️ ห้าม retry โดยไม่มี idempotency — โดยเฉพาะ payment: network timeout ไม่ได้แปลว่า payment gateway ไม่ได้รับ request — อาจ charge สำเร็จแล้วแต่ response ไม่กลับ — retry โดยไม่มี idempotency key = double charge — Stripe/Omise บังคับ Idempotency-Key header ทุก 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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

ข้อดี: 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
}
Enter fullscreen mode Exit fullscreen mode

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),
        )
    })
}
Enter fullscreen mode Exit fullscreen mode

⚠️ ห้ามใช้ 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()),
)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Error Budget / SLO — ทำไมต้องมี

SLO: 99.9% ของ requests ต้องสำเร็จภายใน 1 เดือน
Error Budget: 0.1% = ~43 นาที downtime/เดือน

ถ้า error rate สูงเกิน error budget → freeze feature, focus on stability
Enter fullscreen mode Exit fullscreen mode

metric + alert:

// alert rule (Prometheus):
sum(rate(requests_total{status=~"5.."}[5m])) 
  / sum(rate(requests_total[5m])) > 0.001
 pager ดัง  on-call ดู
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
    })
}
Enter fullscreen mode Exit fullscreen mode

ทุก log ควรมี request_id:

slog.ErrorContext(ctx, "payment failed",
    slog.String("request_id", requestID),
    slog.String("trace_id", traceID),
    slog.String("error", err.Error()),
)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

client จะได้:

{"code": "ERR_USER_NOT_FOUND", "message": "user with id 42 not found"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

💡 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
Enter fullscreen mode Exit fullscreen mode

📚 อ่านต่อ:

Top comments (0)