Files
cv-site/doc/_go-learning/patterns/04-error-wrapping.md
T
juanatsap d95c62bad4 refactor: remove outdated server design documentation
Remove 557-line server-design.md from _go-learning/architecture - content is now covered in updated architecture documentation with real implementation examples and test coverage.
2025-12-02 20:25:05 +00:00

13 KiB

Error Wrapping Pattern in Go

Pattern Overview

Error Wrapping adds context to errors as they propagate up the call stack, creating a chain of errors that preserves both the original error and contextual information. Go 1.13+ provides fmt.Errorf with %w verb and errors.Unwrap for this pattern.

Pattern Structure

// Wrap error with context
if err != nil {
    return fmt.Errorf("operation failed: %w", err)
}

// Unwrap to get original error
originalErr := errors.Unwrap(wrappedErr)

// Check if error chain contains specific error
if errors.Is(err, ErrNotFound) {
    // Handle not found
}

// Extract error of specific type from chain
var domainErr *DomainError
if errors.As(err, &domainErr) {
    // Use domain error
}

Real Implementation from Project

Basic Error Wrapping

// internal/models/cv/cv.go

func LoadCV(lang string) (*CV, error) {
    // Build file path
    filePath := fmt.Sprintf("data/cv-%s.json", lang)

    // Read file
    data, err := os.ReadFile(filePath)
    if err != nil {
        // Wrap with context
        return nil, fmt.Errorf("failed to read CV file: %w", err)
    }

    // Parse JSON
    var cv CV
    err = json.Unmarshal(data, &cv)
    if err != nil {
        // Wrap with more context
        return nil, fmt.Errorf("failed to parse CV JSON: %w", err)
    }

    // Validate
    if err := cv.Validate(); err != nil {
        // Wrap validation error
        return nil, fmt.Errorf("CV validation failed: %w", err)
    }

    return &cv, nil
}

Domain Error Type

// internal/handlers/errors.go

// DomainError represents application-level errors
type DomainError struct {
    Code       ErrorCode  // Machine-readable error code
    Message    string     // Human-readable message
    Err        error      // Underlying error
    StatusCode int        // HTTP status code
    Field      string     // Field that caused error
}

// Error implements error interface
func (e *DomainError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Err)
    }
    return e.Message
}

// Unwrap returns the underlying error
func (e *DomainError) Unwrap() error {
    return e.Err
}

// WithErr adds underlying error
func (e *DomainError) WithErr(err error) *DomainError {
    e.Err = err
    return e
}

// WithField adds field information
func (e *DomainError) WithField(field string) *DomainError {
    e.Field = field
    return e
}

Error Constructors

// internal/handlers/errors.go

// InvalidLanguageError creates a language validation error
func InvalidLanguageError(lang string) *DomainError {
    return NewDomainError(
        ErrCodeInvalidLanguage,
        fmt.Sprintf("Unsupported language: %s (use 'en' or 'es')", lang),
        http.StatusBadRequest,
    ).WithField("lang")
}

// DataNotFoundError creates a data not found error
func DataNotFoundError(dataType, lang string) *DomainError {
    return NewDomainError(
        ErrCodeDataNotFound,
        fmt.Sprintf("%s data not found for language: %s", dataType, lang),
        http.StatusInternalServerError,
    )
}

// PDFGenerationError creates a PDF generation error
func PDFGenerationError(err error) *DomainError {
    return NewDomainError(
        ErrCodePDFGeneration,
        "Failed to generate PDF. Please try again.",
        http.StatusInternalServerError,
    ).WithErr(err)
}

Error Handling Chain

// internal/handlers/cv_pages.go

func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
    // Validate language
    if err := validateLanguage(lang); err != nil {
        // err is already a DomainError
        h.HandleError(w, r, err)
        return
    }

    // Load CV data
    cv, err := cvmodel.LoadCV(lang)
    if err != nil {
        // Wrap in DomainError with context
        domErr := DataNotFoundError("CV", lang).WithErr(err)
        h.HandleError(w, r, domErr)
        return
    }

    // Render template
    if err := h.tmpl.Render(w, "index.html", data); err != nil {
        // Wrap template error
        domErr := TemplateError(err)
        h.HandleError(w, r, domErr)
        return
    }
}

Centralized Error Handler

// internal/handlers/errors.go

// HandleError processes errors and sends appropriate HTTP response
func (h *CVHandler) HandleError(w http.ResponseWriter, r *http.Request, err error) {
    // Try to cast to DomainError
    var domErr *DomainError
    if errors.As(err, &domErr) {
        // Handle domain error
        h.handleDomainError(w, r, domErr)
        return
    }

    // Check for specific errors
    if errors.Is(err, context.Canceled) {
        // Client disconnected
        log.Printf("Request canceled: %v", err)
        return
    }

    if errors.Is(err, context.DeadlineExceeded) {
        // Timeout
        domErr := NewDomainError(
            ErrCodeTimeout,
            "Request timed out",
            http.StatusGatewayTimeout,
        )
        h.handleDomainError(w, r, domErr)
        return
    }

    // Generic error
    log.Printf("Unhandled error: %v", err)
    domErr := NewDomainError(
        ErrCodeInternalError,
        "An unexpected error occurred",
        http.StatusInternalServerError,
    )
    h.handleDomainError(w, r, domErr)
}

func (h *CVHandler) handleDomainError(w http.ResponseWriter, r *http.Request, domErr *DomainError) {
    // Log error with code
    log.Printf("[ERROR] %s: %s", domErr.Code, domErr.Message)
    if domErr.Err != nil {
        log.Printf("[ERROR] Underlying: %v", domErr.Err)
    }

    // Build error response
    response := NewErrorResponse(
        string(domErr.Code),
        domErr.Message,
    )
    if domErr.Field != "" {
        response.Error.Field = domErr.Field
    }

    // Send JSON response
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(domErr.StatusCode)
    json.NewEncoder(w).Encode(response)
}

Error Chain Example

Full Error Propagation

1. File system error (os.ReadFile)
   ↓
2. Wrapped by model (LoadCV)
   "failed to read CV file: open data/cv-xx.json: no such file"
   ↓
3. Wrapped by handler (Home)
   DataNotFoundError("CV", "xx").WithErr(err)
   ↓
4. Handled by error handler
   {
     "success": false,
     "error": {
       "code": "DATA_NOT_FOUND",
       "message": "CV data not found for language: xx"
     }
   }

Error Chain in Code

// Layer 1: File system
_, err := os.ReadFile("data/cv-xx.json")
// err = &fs.PathError{Op:"open", Path:"data/cv-xx.json", Err:syscall.ENOENT}

// Layer 2: Model
if err != nil {
    return nil, fmt.Errorf("failed to read CV file: %w", err)
}
// err = "failed to read CV file: open data/cv-xx.json: no such file or directory"

// Layer 3: Handler
if err != nil {
    domErr := DataNotFoundError("CV", lang).WithErr(err)
}
// domErr = &DomainError{
//     Code: "DATA_NOT_FOUND",
//     Message: "CV data not found for language: xx",
//     Err: [wrapped error from model],
//     StatusCode: 500,
// }

// Layer 4: Error handler
h.HandleError(w, r, domErr)
// Logs full chain, sends user-friendly JSON

Using errors.Is and errors.As

errors.Is - Check Error Type

func handleError(err error) {
    // Check if error is or wraps specific error
    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("File not found")
        return
    }

    if errors.Is(err, context.Canceled) {
        fmt.Println("Request canceled")
        return
    }

    if errors.Is(err, sql.ErrNoRows) {
        fmt.Println("No data found")
        return
    }

    fmt.Println("Unknown error:", err)
}

errors.As - Extract Error Type

func handleError(err error) {
    // Extract DomainError from chain
    var domErr *DomainError
    if errors.As(err, &domErr) {
        fmt.Printf("Domain error: code=%s, status=%d\n",
            domErr.Code, domErr.StatusCode)
        return
    }

    // Extract PathError from chain
    var pathErr *fs.PathError
    if errors.As(err, &pathErr) {
        fmt.Printf("Path error: op=%s, path=%s\n",
            pathErr.Op, pathErr.Path)
        return
    }

    fmt.Println("Unknown error:", err)
}

Custom Error Types

Sentinel Errors

// Define sentinel errors for comparison
var (
    ErrNotFound    = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrInvalidInput = errors.New("invalid input")
)

// Use in code
if user == nil {
    return ErrNotFound
}

// Check with errors.Is
if errors.Is(err, ErrNotFound) {
    // Handle not found
}

Error with Context

// ValidationError includes field information
type ValidationError struct {
    Field   string
    Message string
    Value   interface{}
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed for %s: %s (value: %v)",
        e.Field, e.Message, e.Value)
}

// Usage
if len(name) == 0 {
    return &ValidationError{
        Field:   "name",
        Message: "name is required",
        Value:   name,
    }
}

Error Wrapping Best Practices

DO

// Add context when wrapping
if err != nil {
    return fmt.Errorf("failed to process user %d: %w", userID, err)
}

// Use %w for wrapping (preserves error chain)
return fmt.Errorf("database query failed: %w", err)

// Wrap at each layer
func LoadUser(id int) (*User, error) {
    data, err := readFile(fmt.Sprintf("users/%d.json", id))
    if err != nil {
        return nil, fmt.Errorf("load user %d: %w", id, err)
    }
    // ...
}

// Create custom errors with context
func InvalidEmailError(email string) error {
    return fmt.Errorf("invalid email format: %s", email)
}

// Check errors with errors.Is
if errors.Is(err, os.ErrNotExist) {
    // Handle file not found
}

// Extract errors with errors.As
var domErr *DomainError
if errors.As(err, &domErr) {
    // Use domain error
}

DON'T

// DON'T use %v (loses error chain)
return fmt.Errorf("failed: %v", err)  // Wrong!
return fmt.Errorf("failed: %w", err)  // Correct

// DON'T ignore errors
data, _ := readFile(path)  // Wrong!

// DON'T return generic errors
if invalid {
    return errors.New("error")  // Too generic!
}

// DON'T compare errors with ==
if err == someError {  // Wrong! Use errors.Is
    // ...
}

// DON'T type assert directly
domErr := err.(*DomainError)  // Wrong! Use errors.As

Error Logging

Structured Logging

func (h *Handler) processRequest(r *http.Request) error {
    err := h.doWork()
    if err != nil {
        // Log with context
        log.Printf("[ERROR] Request processing failed: %v", err)

        // Log underlying errors
        var domErr *DomainError
        if errors.As(err, &domErr) {
            log.Printf("[ERROR] Code: %s, Status: %d",
                domErr.Code, domErr.StatusCode)
            if domErr.Err != nil {
                log.Printf("[ERROR] Underlying: %v", domErr.Err)
            }
        }

        return err
    }
    return nil
}

Stack Traces

// For panics (recovered in middleware)
func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                // Log with stack trace
                log.Printf("PANIC: %v\n%s", err, debug.Stack())

                http.Error(w, "Internal Server Error",
                    http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Testing Error Handling

func TestLoadCV_FileNotFound(t *testing.T) {
    // Test error wrapping
    _, err := LoadCV("nonexistent")

    // Check error occurred
    if err == nil {
        t.Fatal("expected error, got nil")
    }

    // Check error message contains context
    if !strings.Contains(err.Error(), "failed to read CV file") {
        t.Errorf("error missing context: %v", err)
    }

    // Check error chain contains specific error
    if !errors.Is(err, os.ErrNotExist) {
        t.Error("error should wrap os.ErrNotExist")
    }
}

func TestHandleError_DomainError(t *testing.T) {
    // Create domain error
    domErr := InvalidLanguageError("xx")

    // Test handling
    w := httptest.NewRecorder()
    req := httptest.NewRequest(http.MethodGet, "/", nil)

    handler.HandleError(w, req, domErr)

    // Verify response
    if w.Code != http.StatusBadRequest {
        t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
    }

    var response APIResponse
    json.NewDecoder(w.Body).Decode(&response)

    if response.Success {
        t.Error("expected success=false")
    }

    if response.Error.Code != "INVALID_LANGUAGE" {
        t.Errorf("code = %s, want INVALID_LANGUAGE", response.Error.Code)
    }
}
  • Handler Pattern: Uses error wrapping for error handling
  • Context Pattern: context.Canceled and context.DeadlineExceeded errors
  • Factory Pattern: Error constructors create wrapped errors

Further Reading