⚠️ Error Handling in Go

Master Go's explicit error handling philosophy with comprehensive patterns for building robust, maintainable applications

📚 Theory: Go's Error Philosophy

Go takes a unique approach to error handling that emphasizes explicit error checking over exceptions. This design choice makes error paths clear and encourages developers to handle errors where they occur.

Why Explicit Error Handling?

Go's designers chose explicit error values over exceptions for several reasons:

  • Clarity: Error handling is visible in the code flow
  • Control: Developers decide how to handle each error
  • Performance: No exception unwinding overhead
  • Simplicity: Errors are just values that can be passed around

The Error Interface

// The error interface is built into Go
type error interface {
    Error() string
}
Function Call Operation Success/Error? Return Value, nil Return zero, error Check Error if err != nil

Error Handling Patterns

Pattern Use Case Example
Simple Check Basic error checking if err != nil { return err }
Wrap & Return Add context to errors return fmt.Errorf("context: %w", err)
Handle & Continue Non-fatal errors if err != nil { log.Warn(err) }
Sentinel Check Check specific errors if errors.Is(err, ErrNotFound)
Type Assertion Extract error details var e *CustomErr; errors.As(err, &e)

Basic Error Creation and Handling

package main

import (
    "errors"
    "fmt"
    "os"
    "log"
)

// Basic error creation
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// Error wrapping with context (Go 1.13+)
func readFile(filename string) ([]byte, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        // Wrap error with %w verb for error chain
        return nil, fmt.Errorf("failed to read file %s: %w", filename, err)
    }
    return data, nil
}

func main() {
    // Always check errors immediately
    result, err := divide(10, 0)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Printf("Result: %.2f\n", result)
    
    // Early return pattern - fail fast
    data, err := readFile("config.json")
    if err != nil {
        log.Fatal(err) // Exits program with error
    }
    
    fmt.Printf("Read %d bytes\n", len(data))
}

🎨 Custom Error Types

Custom error types provide rich context, structured data, and type-safe error handling. They're essential for building maintainable applications.

Struct Errors

Use structs to carry additional error context and metadata.

  • Field-level validation errors
  • HTTP status codes
  • Error timestamps
  • Request IDs for tracing

Sentinel Errors

Predefined error variables for common error conditions.

  • io.EOF for end of file
  • sql.ErrNoRows for no results
  • Custom domain errors
  • Use with errors.Is()

Error Wrapping

Preserve error context through the call stack.

  • Add context with fmt.Errorf
  • Maintain error chain
  • Use %w for wrapping
  • Unwrap with errors.Unwrap
// Custom error with additional context
type ValidationError struct {
    Field   string
    Value   interface{}
    Message string
}

func (e ValidationError) Error() string {
    return fmt.Sprintf("validation error on field '%s': %s (got: %v)", 
        e.Field, e.Message, e.Value)
}

// Error with error code and wrapping support
type AppError struct {
    Code    string
    Message string
    Err     error
}

func (e AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

func (e AppError) Unwrap() error {
    return e.Err
}

// Sentinel errors for common conditions
var (
    ErrNotFound      = errors.New("resource not found")
    ErrUnauthorized  = errors.New("unauthorized access")
    ErrInvalidInput  = errors.New("invalid input")
    ErrRateLimited   = errors.New("rate limit exceeded")
)

⛓️ Error Chains and Context

Go 1.13 introduced error wrapping, allowing errors to maintain context as they propagate up the call stack.

Error Chain Visualization

Original Error Service Layer Wrap Controller Layer Wrap errors.Is() Check Unwrap chain: 1. Controller context 2. Service context 3. Original error
import (
    "errors"
    "fmt"
)

// Wrapping errors through layers
func dbQuery(id string) error {
    return ErrNotFound // Original error
}

func getUser(id string) error {
    err := dbQuery(id)
    if err != nil {
        return fmt.Errorf("getUser %s: %w", id, err) // Wrap
    }
    return nil
}

func handleRequest(id string) error {
    err := getUser(id)
    if err != nil {
        return fmt.Errorf("request failed: %w", err) // Wrap again
    }
    return nil
}

// Checking and unwrapping errors
func main() {
    err := handleRequest("123")
    
    // Check if error chain contains ErrNotFound
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User not found")
    }
    
    // Extract specific error type from chain
    var appErr *AppError
    if errors.As(err, &appErr) {
        fmt.Printf("App error code: %s\n", appErr.Code)
    }
    
    // Walk the error chain
    for err != nil {
        fmt.Printf("Error: %v\n", err)
        err = errors.Unwrap(err)
    }
}

🔄 Error Handling Patterns

Common patterns for handling errors effectively in production Go applications.

// Retry pattern with exponential backoff
func retryWithBackoff(fn func() error, maxRetries int) error {
    var err error
    backoff := time.Second
    
    for i := 0; i < maxRetries; i++ {
        if err = fn(); err == nil {
            return nil
        }
        
        if i < maxRetries-1 {
            time.Sleep(backoff)
            backoff *= 2
        }
    }
    
    return fmt.Errorf("failed after %d retries: %w", maxRetries, err)
}

// Error aggregation for batch operations
type MultiError struct {
    Errors []error
}

func (m *MultiError) Add(err error) {
    if err != nil {
        m.Errors = append(m.Errors, err)
    }
}

func (m MultiError) Error() string {
    if len(m.Errors) == 0 {
        return "no errors"
    }
    
    var msgs []string
    for _, err := range m.Errors {
        msgs = append(msgs, err.Error())
    }
    return strings.Join(msgs, "; ")
}

// Cleanup with defer and error capture
func processFile(filename string) (err error) {
    f, err := os.Open(filename)
    if err != nil {
        return err
    }
    
    // Ensure file is closed, capture any close error
    defer func() {
        if closeErr := f.Close(); closeErr != nil && err == nil {
            err = closeErr
        }
    }()
    
    // Process file...
    return nil
}

// Panic recovery for critical sections
func safeOperation() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()
    
    // Operation that might panic
    riskyOperation()
    return nil
}

📊 Structured Error Logging

Enhance error visibility with structured logging and contextual information.

import (
    "log/slog"
    "context"
)

// Error with structured logging support
type LoggableError struct {
    Op      string
    Kind    string
    Err     error
    Details map[string]interface{}
}

func (e LoggableError) Error() string {
    return fmt.Sprintf("%s: %s: %v", e.Op, e.Kind, e.Err)
}

func (e LoggableError) LogValue() slog.Value {
    attrs := []slog.Attr{
        slog.String("op", e.Op),
        slog.String("kind", e.Kind),
    }
    
    for k, v := range e.Details {
        attrs = append(attrs, slog.Any(k, v))
    }
    
    if e.Err != nil {
        attrs = append(attrs, slog.String("error", e.Err.Error()))
    }
    
    return slog.GroupValue(attrs...)
}

// Context-aware error handling
func processWithContext(ctx context.Context, id string) error {
    logger := slog.Default().With("request_id", id)
    
    data, err := fetchData(ctx, id)
    if err != nil {
        logger.Error("failed to fetch data",
            "error", err,
            "id", id,
        )
        return LoggableError{
            Op:   "processWithContext",
            Kind: "fetch",
            Err:  err,
            Details: map[string]interface{}{
                "id": id,
            },
        }
    }
    
    return nil
}

⚡ Performance and Best Practices

Error Handling Performance

Approach Performance Memory Use Case
errors.New() Fast Low Static error messages
fmt.Errorf() Slower Medium Dynamic error messages
Custom Types Fast Variable Rich error context
Panic/Recover Very Slow High Exceptional cases only

Error Handling Best Practices

  • Fail fast: Return errors immediately when detected
  • Add context: Wrap errors with meaningful messages
  • Log once: Avoid logging the same error multiple times
  • Handle or return: Don't do both - choose one
  • Use error types: Create custom types for domain errors
  • Document errors: Specify which errors functions can return
  • Test error paths: Write tests for error conditions

Common Anti-Patterns to Avoid

  • Ignoring errors: Never use _ = someFunc()
  • Generic errors: Avoid errors.New("error")
  • String comparison: Don't compare err.Error() == "text"
  • Panic for flow control: Reserve panic for truly exceptional cases
  • Nested error handling: Keep error handling flat and simple

🏋️ Practice Exercises

Challenge 1: Retry with Backoff

Implement a retry mechanism with exponential backoff for network operations:

  • Retry failed operations up to N times
  • Implement exponential backoff between retries
  • Support context cancellation
  • Return all errors if max retries exceeded

Challenge 2: Error Aggregator

Create an error aggregator for batch operations:

  • Collect multiple errors from concurrent operations
  • Implement custom Error() method
  • Support error categorization
  • Provide detailed error reporting

Challenge 3: Circuit Breaker

Build a circuit breaker pattern for fault tolerance:

  • Track error rates over time
  • Open circuit on threshold breach
  • Implement half-open state for recovery
  • Support custom error predicates

Challenge 4: Error Reporter

Design an error reporting system:

  • Capture error context and stack traces
  • Group similar errors together
  • Support error severity levels
  • Implement rate limiting for error reports