Understanding Context in Depth

Context in Go serves as a mechanism for managing request-scoped values, cancellation signals, and deadlines across API boundaries. Unlike simple channel-based cancellation, context provides a structured approach to propagate cancellation signals through call stacks.

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // Create a context with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    // Start multiple goroutines that respect the context
    results := make(chan string, 3)
    
    go worker(ctx, "worker-1", 1*time.Second, results)
    go worker(ctx, "worker-2", 3*time.Second, results)
    go worker(ctx, "worker-3", 1*time.Second, results)

    // Collect results or handle cancellation
    for i := 0; i < 3; i++ {
        select {
        case result := <-results:
            fmt.Println("Result:", result)
        case <-ctx.Done():
            fmt.Println("Context cancelled:", ctx.Err())
            return
        }
    }
}

func worker(ctx context.Context, name string, duration time.Duration, results chan<- string) {
    select {
    case <-time.After(duration):
        results <- fmt.Sprintf("%s completed", name)
    case <-ctx.Done():
        results <- fmt.Sprintf("%s cancelled: %v", name, ctx.Err())
    }
}

The key insight here is that context cancellation propagates automatically through the call stack. When cancel() is called, all goroutines that respect the context will receive the cancellation signal, preventing resource leaks and ensuring graceful shutdown.

Advanced Error Handling Patterns

Traditional error handling in Go often involves checking errors after each operation. However, in concurrent environments, we need more sophisticated approaches to aggregate and propagate errors effectively.

package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

// ErrorGroup provides a way to collect errors from concurrent operations
type ErrorGroup struct {
    mu    sync.Mutex
    errs  []error
    wg    sync.WaitGroup
}

func (g *ErrorGroup) Go(f func() error) {
    g.wg.Add(1)
    go func() {
        defer g.wg.Done()
        if err := f(); err != nil {
            g.mu.Lock()
            g.errs = append(g.errs, err)
            g.mu.Unlock()
        }
    }()
}

func (g *ErrorGroup) Wait() []error {
    g.wg.Wait()
    return g.errs
}

func main() {
    eg := &ErrorGroup{}
    
    // Simulate concurrent operations that may fail
    for i := 0; i < 5; i++ {
        i := i // capture loop variable
        eg.Go(func() error {
            return simulateOperation(i)
        })
    }
    
    errs := eg.Wait()
    if len(errs) > 0 {
        fmt.Printf("Encountered %d errors:\n", len(errs))
        for _, err := range errs {
            fmt.Println("  -", err)
        }
    } else {
        fmt.Println("All operations completed successfully")
    }
}

func simulateOperation(id int) error {
    // Simulate some work with random success/failure
    if id%3 == 0 {
        return fmt.Errorf("operation %d failed", id)
    }
    time.Sleep(time.Duration(id) * 100 * time.Millisecond)
    return nil
}

Context With Values and Metadata

Context's ability to carry values makes it incredibly powerful for request-scoped data. This pattern is commonly used in HTTP handlers to pass request IDs, user information, or other metadata.

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

type key string

const (
    requestIDKey key = "requestID"
    userIDKey    key = "userID"
)

func withRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey, id)
}

func withUserID(ctx context.Context, userID string) context.Context {
    return context.WithValue(ctx, userIDKey, userID)
}

func requestID(ctx context.Context) string {
    if id, ok := ctx.Value(requestIDKey).(string); ok {
        return id
    }
    return "unknown"
}

func userID(ctx context.Context) string {
    if id, ok := ctx.Value(userIDKey).(string); ok {
        return id
    }
    return "anonymous"
}

func middleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        ctx = withRequestID(ctx, generateRequestID())
        ctx = withUserID(ctx, getUserID(r))
        
        next(w, r.WithContext(ctx))
    }
}

func generateRequestID() string {
    return fmt.Sprintf("req-%d", time.Now().UnixNano())
}

func getUserID(r *http.Request) string {
    // Simplified example - in practice, extract from auth headers
    return "user-123"
}

func apiHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    fmt.Fprintf(w, "Request ID: %s, User ID: %s\n", 
        requestID(ctx), userID(ctx))
}

Cancellation and Timeout Strategies

Effective cancellation requires understanding when and how to cancel operations. Different strategies work better for different scenarios.

StrategyUse CaseAdvantagesDisadvantages
Timeout ContextLong-running operationsPrevents resource exhaustionMay cancel successful operations
Cancel ContextUser-initiated cancellationImmediate response to user actionsRequires explicit cancellation logic
Deadline ContextTime-sensitive operationsBuilt-in timeout handlingLess flexible than manual cancellation
package main

import (
    "context"
    "fmt"
    "time"
)

// Cancellable operation with multiple cancellation points
func cancellableOperation(ctx context.Context, workDuration time.Duration) error {
    // Simulate work with periodic checks for cancellation
    ticker := time.NewTicker(100 * time.Millisecond)
    defer ticker.Stop()
    
    startTime := time.Now()
    
    for {
        select {
        case <-ticker.C:
            if time.Since(startTime) > workDuration {
                return fmt.Errorf("operation timed out after %v", workDuration)
            }
            // Check if context was cancelled
            select {
            case <-ctx.Done():
                return ctx.Err()
            default:
                // Continue working
            }
        case <-ctx.Done():
            return ctx.Err()
        }
    }
}

// Resource management with context
func resourceIntensiveOperation(ctx context.Context) error {
    // Acquire resources
    resource, err := acquireResource()
    if err != nil {
        return err
    }
    defer releaseResource(resource)
    
    // Use context for operation with timeout
    opCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    
    return performWork(opCtx, resource)
}

func acquireResource() (interface{}, error) {
    // Simulate resource acquisition
    time.Sleep(10 * time.Millisecond)
    return "resource", nil
}

func releaseResource(resource interface{}) {
    // Simulate resource cleanup
    fmt.Println("Resource released")
}

func performWork(ctx context.Context, resource interface{}) error {
    // Simulate work with context awareness
    select {
    case <-time.After(3 * time.Second):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func main() {
    // Test with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    
    err := cancellableOperation(ctx, 1*time.Second)
    if err != nil {
        fmt.Println("Operation failed:", err)
    } else {
        fmt.Println("Operation completed successfully")
    }
}

Best Practices for Concurrent Error Handling

  1. Use Error Groups: For collecting errors from multiple goroutines
  2. Implement Context Propagation: Ensure all functions respect context cancellation
  3. Avoid Panic in Goroutines: Always handle errors explicitly
  4. Use Select Statements: For graceful handling of multiple channels
  5. Log Context Cancellations: Track when operations are cancelled for debugging

Learn more with useful resources