Day 17: Error Types and Custom Error Handling Strategies

Venkat Annangi
Venkat Annangi
26/11/2024 15:00 5 min read 45 views
#go custom errors #golang series #108 days of golang #go errors
Day 17: Error Types and Custom Error Handling Strategies

Error Types and Custom Error Handling Strategies in Go

A comprehensive guide to understanding error types, implementing robust error handling strategies, and building reliable applications in Go.

 

Introduction to Error Handling in Go

Go's approach to error handling stands out among modern programming languages, emphasizing explicit error checking rather than exception handling mechanisms found in languages like Java or Python. This design choice reflects Go's philosophy of simplicity and explicit control flow.

At its core, Go represents errors using the built-in error interface:

type error interface {
    Error() string
}

This simple interface is the foundation of Go's error handling system, allowing for both straightforward error reporting and sophisticated error handling patterns.

Basic Error Handling Patterns

Let's examine a basic example of error handling in Go:

package main
import (
    "errors"
    "fmt"
)
func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("cannot divide by zero")
    }
    return a / b, nil
}
func main() {
    result, err := divide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result)
}

This pattern of returning both a result and an error is idiomatic in Go. It enables clear error handling and makes error checks explicit in the code.

Types of Errors in Go

1. Standard Library Errors

The Go standard library provides several error types and functions for common scenarios:

// File operations
file, err := os.Open("nonexistent.txt")
if err != nil {
    if os.IsNotExist(err) {
        fmt.Println("File doesn't exist")
    } else {
        fmt.Println("Other error:", err)
    }
}
// Network operations
conn, err := net.Dial("tcp", "localhost:12345")
if err != nil {
    if netErr, ok := err.(net.Error); ok {
        if netErr.Timeout() {
            fmt.Println("Connection timeout")
        }
    }
}

2. Custom Errors

Creating custom errors allows you to provide specific error information:

// Using errors.New
var ErrInvalidInput = errors.New("invalid input provided")
// Using fmt.Errorf
func processUser(id string) error {
    return fmt.Errorf("failed to process user %s: invalid format", id)
}

3. Sentinel Errors

Sentinel errors are predefined error values that can be used for comparison:

package main
import (
    "errors"
    "fmt"
)
var (
    ErrNotFound = errors.New("resource not found")
    ErrTimeout  = errors.New("operation timed out")
    ErrInvalid  = errors.New("invalid input")
)
func fetchResource(id string) error {
    // Simulate resource fetch
    return ErrNotFound
}
func main() {
    err := fetchResource("123")
    if errors.Is(err, ErrNotFound) {
        fmt.Println("Resource not found, please try a different ID")
    }
}

4. Error Wrapping

Go 1.13 introduced error wrapping, allowing you to add context while preserving the original error:

package main
import (
    "database/sql"
    "fmt"
    "errors"
)
func queryDatabase(id string) error {
    err := sql.ErrNoRows
    if err != nil {
        return fmt.Errorf("failed to query user %s: %w", id, err)
    }
    return nil
}
func main() {
    err := queryDatabase("123")
    if errors.Is(err, sql.ErrNoRows) {
        fmt.Println("No user found")
    }
    
    // Print the error chain
    fmt.Printf("Error: %v\n", err)
}

Advanced Error Types and Patterns

Custom Error Types with Additional Context

type ValidationError struct {
    Field     string
    Value     interface{}
    Constraint string
}
func (v *ValidationError) Error() string {
    return fmt.Sprintf(
        "validation failed for field '%s': value '%v' violates constraint '%s'",
        v.Field,
        v.Value,
        v.Constraint,
    )
}
// Example usage
func validateUser(user User) error {
    if len(user.Username) < 3 {
        return &ValidationError{
            Field:     "username",
            Value:     user.Username,
            Constraint: "minimum length 3 characters",
        }
    }
    return nil
}

Error Type Hierarchies

type AppError struct {
    Err     error
    Message string
    Code    int
}
func (e *AppError) Error() string {
    return fmt.Sprintf("%s (code: %d)", e.Message, e.Code)
}
func (e *AppError) Unwrap() error {
    return e.Err
}
// Domain-specific errors
type DatabaseError struct {
    *AppError
    Query string
}
func NewDatabaseError(err error, query string) *DatabaseError {
    return &DatabaseError{
        AppError: &AppError{
            Err:     err,
            Message: "database operation failed",
            Code:    5001,
        },
        Query: query,
    }
}

Error Handling Best Practices

1. Immediate Error Checking

  • Check errors immediately after function calls
  • Don't pass errors around unnecessarily
  • Handle errors at the appropriate level of abstraction

2. Error Wrapping Guidelines

  • Add context when wrapping errors
  • Avoid deep nesting of wrapped errors
  • Use meaningful error messages

3. Error Handling Patterns

// Pattern 1: Return early for errors
func processItem(item Item) error {
    if err := validate(item); err != nil {
        return fmt.Errorf("validation failed: %w", err)
    }
    if err := save(item); err != nil {
        return fmt.Errorf("save failed: %w", err)
    }
    return nil
}
// Pattern 2: Error handling middleware
type Handler func(Context) error
func WithErrorHandling(h Handler) Handler {
    return func(ctx Context) error {
        err := h(ctx)
        if err != nil {
            // Log error
            log.Printf("Error: %v", err)
            // Convert to appropriate response
            return convertError(err)
        }
        return nil
    }
}
// Pattern 3: Error aggregation
type ErrorGroup struct {
    errors []error
}
func (g *ErrorGroup) Add(err error) {
    if err != nil {
        g.errors = append(g.errors, err)
    }
}
func (g *ErrorGroup) Err() error {
    if len(g.errors) == 0 {
        return nil
    }
    return fmt.Errorf("multiple errors occurred: %v", g.errors)
}

4. Testing Error Handling

func TestDivide(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        want     int
        wantErr  bool
        errMsg   string
    }{
        {"valid division", 10, 2, 5, false, ""},
        {"division by zero", 10, 0, 0, true, "cannot divide by zero"},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := divide(tt.a, tt.b)
            if (err != nil) != tt.wantErr {
                t.Errorf("divide() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if tt.wantErr && err.Error() != tt.errMsg {
                t.Errorf("divide() error message = %v, want %v", err, tt.errMsg)
                return
            }
            if got != tt.want {
                t.Errorf("divide() = %v, want %v", got, tt.want)
            }
        })
    }
}

Conclusion

Effective error handling is crucial for building robust Go applications. By understanding and implementing these patterns and best practices, you can create more maintainable and reliable code. Remember to:

  • Choose appropriate error types for your use case
  • Add meaningful context to errors
  • Handle errors at the right level of abstraction
  • Test error handling thoroughly
  • Document error handling behavior

Comments