Error Handling in Go

Error Handling in Go

Go handles errors differently from many other languages. Instead of using exceptions, Go uses explicit error handling where functions that can fail return an error value as their last return value.

The Error Interface

In Go, errors are values that implement the built-in error interface:

Error Interface Definition
type error interface {
    Error() string
}

Basic Error Handling

Simple Error Example

package main

import (
    "errors"
    "fmt"
    "strconv"
)

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

func main() {
    result, err := divide(10, 2)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result) // Output: Result: 5
    
    result, err = divide(10, 0)
    if err != nil {
        fmt.Println("Error:", err) // Output: Error: cannot divide by zero
        return
    }
    fmt.Println("Result:", result)
    
    // Example with strconv
    str := "123"
    num, err := strconv.Atoi(str)
    if err != nil {
        fmt.Println("Error converting string to int:", err)
        return
    }
    fmt.Println("Converted number:", num)
    
    str2 := "abc"
    num2, err := strconv.Atoi(str2)
    if err != nil {
        fmt.Println("Error converting string to int:", err)
        // Output: Error converting string to int: strconv.Atoi: parsing "abc": invalid syntax
        return
    }
    fmt.Println("Converted number:", num2)
}

Creating Custom Errors

package main

import "fmt"

// Custom error type
type ValidationError struct {
    Field   string
    Message string
}

func (ve ValidationError) Error() string {
    return fmt.Sprintf("validation error for field '%s': %s", ve.Field, ve.Message)
}

type User struct {
    Name  string
    Email string
    Age   int
}

func (u User) Validate() error {
    if u.Name == "" {
        return ValidationError{Field: "name", Message: "name cannot be empty"}
    }
    
    if u.Email == "" {
        return ValidationError{Field: "email", Message: "email cannot be empty"}
    }
    
    if u.Age < 0 || u.Age > 120 {
        return ValidationError{Field: "age", Message: "age must be between 0 and 120"}
    }
    
    return nil
}

func main() {
    users := []User{
        {Name: "John", Email: "[email protected]", Age: 30},
        {Name: "", Email: "[email protected]", Age: 25},
        {Name: "Bob", Email: "[email protected]", Age: -5},
    }
    
    for i, user := range users {
        fmt.Printf("Validating user %d...\n", i+1)
        
        if err := user.Validate(); err != nil {
            fmt.Println("Validation failed:", err)
        } else {
            fmt.Println("Validation passed!")
        }
        fmt.Println()
    }
}

Error Wrapping with fmt.Errorf

Go 1.13 introduced error wrapping with %w verb:

package main

import (
    "errors"
    "fmt"
)

func processFile(filename string) error {
    // Simulate a file operation error
    return fmt.Errorf("failed to process file '%s': %w", filename, 
        errors.New("permission denied"))
}

func main() {
    err := processFile("/etc/config.yaml")
    if err != nil {
        fmt.Println("Error:", err)
        
        // Check if the error contains a specific wrapped error
        if errors.Is(err, errors.New("permission denied")) {
            fmt.Println("This is a permission error!")
        }
    }
}

Error Types and Type Assertions

Working with Different Error Types

package main

import (
    "fmt"
    "net"
    "os"
)

type NetworkError struct {
    Op   string
    Net  string
    Addr string
    Err  error
}

func (ne NetworkError) Error() string {
    return fmt.Sprintf("network error: %s %s %s: %v", ne.Op, ne.Net, ne.Addr, ne.Err)
}

func (ne NetworkError) Unwrap() error {
    return ne.Err
}

func connectToServer(address string) error {
    conn, err := net.Dial("tcp", address)
    if err != nil {
        return NetworkError{
            Op:   "dial",
            Net:  "tcp",
            Addr: address,
            Err:  err,
        }
    }
    defer conn.Close()
    
    fmt.Println("Connected successfully!")
    return nil
}

func main() {
    // Try to connect to a non-existent server
    err := connectToServer("localhost:9999")
    if err != nil {
        fmt.Println("Connection error:", err)
        
        // Type assertion to get the specific error type
        if netErr, ok := err.(NetworkError); ok {
            fmt.Printf("Operation: %s\n", netErr.Op)
            fmt.Printf("Address: %s\n", netErr.Addr)
            fmt.Printf("Underlying error: %v\n", netErr.Err)
        }
        
        // Check for specific underlying errors
        if errors.Is(err, os.ErrPermission) {
            fmt.Println("This is a permission error")
        }
    }
}

Best Practices for Error Handling

1. Always Handle Errors

// Bad: Ignoring errors
file, _ := os.Open("config.txt")
data, _ := ioutil.ReadAll(file)

// Good: Handling errors
file, err := os.Open("config.txt")
if err != nil {
    log.Printf("Failed to open config file: %v", err)
    return
}
defer file.Close()

data, err := ioutil.ReadAll(file)
if err != nil {
    log.Printf("Failed to read config file: %v", err)
    return
}

2. Provide Context in Errors

// Bad: Vague error
func processData(data []byte) error {
    if len(data) == 0 {
        return errors.New("invalid data")
    }
    // ... process data
}

// Good: Specific error with context
func processData(data []byte) error {
    if len(data) == 0 {
        return fmt.Errorf("processData: data slice is empty")
    }
    // ... process data
}

3. Use Sentinel Errors for Known Conditions

package main

import (
    "errors"
    "fmt"
)

// Sentinel errors
var (
    ErrUserNotFound    = errors.New("user not found")
    ErrInvalidPassword = errors.New("invalid password")
    ErrAccountLocked   = errors.New("account is locked")
)

type User struct {
    ID       string
    Username string
    Password string
    Locked   bool
}

func authenticate(username, password string) (*User, error) {
    users := map[string]User{
        "alice": {ID: "1", Username: "alice", Password: "secret123", Locked: false},
        "bob":   {ID: "2", Username: "bob", Password: "password", Locked: true},
    }
    
    user, exists := users[username]
    if !exists {
        return nil, ErrUserNotFound
    }
    
    if user.Locked {
        return nil, ErrAccountLocked
    }
    
    if user.Password != password {
        return nil, ErrInvalidPassword
    }
    
    return &user, nil
}

func main() {
    testCases := []struct {
        username string
        password string
    }{
        {"alice", "secret123"},
        {"bob", "password"},
        {"charlie", "nopass"},
        {"alice", "wrongpass"},
    }
    
    for _, tc := range testCases {
        fmt.Printf("Testing login for %s...\n", tc.username)
        
        user, err := authenticate(tc.username, tc.password)
        if err != nil {
            switch {
            case errors.Is(err, ErrUserNotFound):
                fmt.Printf("  User not found\n")
            case errors.Is(err, ErrInvalidPassword):
                fmt.Printf("  Invalid password\n")
            case errors.Is(err, ErrAccountLocked):
                fmt.Printf("  Account is locked\n")
            default:
                fmt.Printf("  Unknown error: %v\n", err)
            }
        } else {
            fmt.Printf("  Login successful! User ID: %s\n", user.ID)
        }
        fmt.Println()
    }
}

Panic and Recover

While Go encourages explicit error handling, sometimes you need to handle unrecoverable situations with panic.

Using Panic

package main

import (
    "fmt"
    "os"
)

func readFile(filename string) {
    file, err := os.Open(filename)
    if err != nil {
        panic(fmt.Sprintf("Failed to open file %s: %v", filename, err))
    }
    defer file.Close()
    
    fmt.Printf("Successfully opened %s\n", filename)
}

func main() {
    fmt.Println("Starting program...")
    
    // This will panic if the file doesn't exist
    readFile("nonexistent.txt")
    
    fmt.Println("This line won't be reached")
}

Using Recover

package main

import (
    "fmt"
    "log"
)

func riskyOperation() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("Recovered from panic: %v\n", r)
        }
    }()
    
    fmt.Println("Starting risky operation...")
    
    // Simulate a panic
    panic("something went wrong!")
    
    fmt.Println("This won't be printed")
}

func safeOperation() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Panic caught in safeOperation: %v", r)
        }
    }()
    
    riskyOperation()
    
    fmt.Println("This will be printed because we recovered")
}

func main() {
    fmt.Println("Main program starting...")
    
    safeOperation()
    
    fmt.Println("Main program continues...")
}

Practical Error Handling Examples

File Processing with Error Handling

package main

import (
    "bufio"
    "fmt"
    "os"
    "strconv"
    "strings"
)

type Config struct {
    Port     int
    Host     string
    Debug    bool
    LogLevel string
}

func loadConfig(filename string) (*Config, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, fmt.Errorf("loadConfig: failed to open file %s: %w", filename, err)
    }
    defer file.Close()
    
    config := &Config{}
    scanner := bufio.NewScanner(file)
    lineNum := 0
    
    for scanner.Scan() {
        lineNum++
        line := strings.TrimSpace(scanner.Text())
        
        // Skip empty lines and comments
        if line == "" || strings.HasPrefix(line, "#") {
            continue
        }
        
        parts := strings.SplitN(line, "=", 2)
        if len(parts) != 2 {
            return nil, fmt.Errorf("loadConfig: invalid format on line %d: %s", lineNum, line)
        }
        
        key := strings.TrimSpace(parts[0])
        value := strings.TrimSpace(parts[1])
        
        switch key {
        case "port":
            port, err := strconv.Atoi(value)
            if err != nil {
                return nil, fmt.Errorf("loadConfig: invalid port value on line %d: %w", lineNum, err)
            }
            if port < 1 || port > 65535 {
                return nil, fmt.Errorf("loadConfig: port must be between 1 and 65535, got %d on line %d", port, lineNum)
            }
            config.Port = port
            
        case "host":
            config.Host = value
            
        case "debug":
            debug, err := strconv.ParseBool(value)
            if err != nil {
                return nil, fmt.Errorf("loadConfig: invalid debug value on line %d: %w", lineNum, err)
            }
            config.Debug = debug
            
        case "log_level":
            validLevels := []string{"debug", "info", "warn", "error"}
            valid := false
            for _, level := range validLevels {
                if value == level {
                    valid = true
                    break
                }
            }
            if !valid {
                return nil, fmt.Errorf("loadConfig: invalid log level '%s' on line %d", value, lineNum)
            }
            config.LogLevel = value
            
        default:
            return nil, fmt.Errorf("loadConfig: unknown configuration key '%s' on line %d", key, lineNum)
        }
    }
    
    if err := scanner.Err(); err != nil {
        return nil, fmt.Errorf("loadConfig: error reading file: %w", err)
    }
    
    // Set default values
    if config.Port == 0 {
        config.Port = 8080
    }
    if config.Host == "" {
        config.Host = "localhost"
    }
    if config.LogLevel == "" {
        config.LogLevel = "info"
    }
    
    return config, nil
}

func main() {
    // Create a sample config file
    configContent := `# Server Configuration
port=8080
host=localhost
debug=true
log_level=info
`
    
    err := os.WriteFile("config.txt", []byte(configContent), 0644)
    if err != nil {
        fmt.Printf("Failed to create config file: %v\n", err)
        return
    }
    
    // Load the configuration
    config, err := loadConfig("config.txt")
    if err != nil {
        fmt.Printf("Error loading configuration: %v\n", err)
        return
    }
    
    fmt.Printf("Configuration loaded successfully:\n")
    fmt.Printf("  Host: %s\n", config.Host)
    fmt.Printf("  Port: %d\n", config.Port)
    fmt.Printf("  Debug: %t\n", config.Debug)
    fmt.Printf("  Log Level: %s\n", config.LogLevel)
    
    // Clean up
    os.Remove("config.txt")
}

HTTP Server Error Handling

package main

import (
    "errors"
    "fmt"
    "net/http"
)

type APIError struct {
    Code    int
    Message string
    Details string
}

func (ae APIError) Error() string {
    return fmt.Sprintf("API error %d: %s - %s", ae.Code, ae.Message, ae.Details)
}

type User struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

var users = map[string]User{
    "1": {ID: "1", Name: "John Doe", Email: "[email protected]"},
    "2": {ID: "2", Name: "Jane Smith", Email: "[email protected]"},
}

func getUser(id string) (*User, error) {
    if id == "" {
        return nil, APIError{
            Code:    http.StatusBadRequest,
            Message: "missing user ID",
            Details: "User ID is required in the URL path",
        }
    }
    
    user, exists := users[id]
    if !exists {
        return nil, APIError{
            Code:    http.StatusNotFound,
            Message: "user not found",
            Details: fmt.Sprintf("User with ID %s does not exist", id),
        }
    }
    
    return &user, nil
}

func userHandler(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")
    
    user, err := getUser(id)
    if err != nil {
        // Type assertion to check if it's an APIError
        if apiErr, ok := err.(APIError); ok {
            http.Error(w, apiErr.Message, apiErr.Code)
            return
        }
        
        // Handle other types of errors
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }
    
    fmt.Fprintf(w, "User found: %s (%s)", user.Name, user.Email)
}

func main() {
    http.HandleFunc("/user", userHandler)
    
    fmt.Println("Server starting on :8080...")
    fmt.Println("Test with:")
    fmt.Println("  curl 'http://localhost:8080/user?id=1'")
    fmt.Println("  curl 'http://localhost:8080/user?id=999'")
    fmt.Println("  curl 'http://localhost:8080/user'")
    
    if err := http.ListenAndServe(":8080", nil); err != nil {
        fmt.Printf("Server failed to start: %v\n", err)
    }
}

Error Handling Best Practices Summary

  1. Always handle errors returned by functions
  2. Return errors as the last return value
  3. Use sentinel errors for known error conditions
  4. Add context to errors using fmt.Errorf with %w
  5. Create custom error types for complex error scenarios
  6. Use errors.Is and errors.As for error comparison and type checking
  7. Avoid panicking unless you have an unrecoverable error
  8. Use defer with recover sparingly and only when necessary
  9. Be consistent in your error handling approach
  10. Document your errors so callers know what to expect

External Resources:

Related Tutorials:

Last updated on