Go Error Handling and Recovery - Building Robust Applications
Learning Objectives: By the end of this lesson, you will be able to:
- Master Go’s explicit error handling philosophy and patterns
- Create and use custom error types for specific problem domains
- Implement error wrapping and unwrapping for better error context
- Handle panics gracefully with recovery mechanisms
- Design robust error handling strategies for production applications
- Debug and trace errors effectively through application layers
- Apply industry best practices for error management
- Build fault-tolerant systems with proper error boundaries
1. Welcome and Introduction (5 minutes)
Icebreaker Activity
Think about the last time a software application crashed or gave you a cryptic error message. How frustrating was it? Now imagine if every potential problem was clearly communicated with helpful context about what went wrong and how to fix it. That’s the power of Go’s explicit error handling - it forces developers to think about what can go wrong and handle it gracefully.
Lesson Overview
- Duration: 5-7 hours
- Prerequisites: Go functions, interfaces, and basic programming concepts
- Materials Needed: Go development environment, understanding of function returns
2. Learning Objectives and Session Outline (5 minutes)
What We’ll Cover Today:
- Error Interface and Philosophy - Go’s unique approach to error handling
- Custom Error Types - Creating domain-specific error information
- Error Wrapping and Context - Adding context while preserving error chains
- Panic and Recovery - Handling exceptional situations gracefully
- Error Handling Patterns - Industry-standard approaches and best practices
- Testing Error Conditions - Ensuring your error handling works correctly
- Production Considerations - Logging, monitoring, and debugging strategies
- Advanced Techniques - Error boundaries, timeouts, and retry mechanisms
Success Criteria:
By the end of this session, you should be able to design and implement comprehensive error handling strategies that make your Go applications robust and maintainable.
3. Foundation Knowledge (15-20 minutes)
Go’s Error Handling Philosophy
Go takes a unique approach to error handling that differs significantly from exception-based languages. Instead of throwing exceptions, Go uses explicit error returns that must be checked at each call site.
Real-World Analogy: Think of Go’s error handling like a careful craftsperson who checks each step of their work. Rather than hoping nothing goes wrong and dealing with problems later (exceptions), they verify each operation succeeds before proceeding to the next step.
Why This Matters: Explicit error handling provides:
- Predictable Control Flow: No hidden exceptions that can crash your program
- Better Error Context: Errors carry information about what went wrong and where
- Forced Error Consideration: Developers must think about failure cases
- Performance: No exception stack unwinding overhead
- Debugging Clarity: Error paths are visible in the code
Core Concepts
The Error Interface
Go’s built-in error interface is simple but powerful:
type error interface {
Error() string
}
Any type that implements this interface can be used as an error. This simplicity enables powerful error handling patterns.
Error as Values
In Go, errors are values, not exceptions. This means:
- Explicit Returns: Functions return errors alongside normal values
- Explicit Checks: Callers must explicitly check for errors
- Composable: Errors can be inspected, wrapped, and transformed
- Typed: Different error types can carry different information
Panic and Recovery
While Go prefers explicit error handling, it provides panic/recover for truly exceptional situations:
- Panic: Stops normal execution, begins unwinding the stack
- Recovery: Catches panics and resumes normal execution
- Use Cases: Programming errors, impossible conditions, library boundaries
Key Terminology
- Error: Any value implementing the error interface
- Error Wrapping: Adding context to an error while preserving the original
- Error Unwrapping: Extracting the underlying error from a wrapped error
- Panic: Abnormal program termination with stack unwinding
- Recovery: Mechanism to catch and handle panics
- Error Sentinel: Predefined error values for specific conditions
- Error Type: Custom struct implementing error interface
- Error Chain: Series of wrapped errors providing context trail
4. Detailed Explanation and Examples (25-30 minutes)
Understanding Go’s Error Handling Model
Go’s error handling is based on the principle that errors are part of your program’s normal operation. Rather than exceptional control flow, errors represent expected failure conditions that should be handled explicitly.
Why Explicit Error Handling?
- Reliability: Forces consideration of all failure paths
- Performance: No hidden stack unwinding costs
- Clarity: Error handling logic is visible in the code
- Debugging: Clear error propagation paths
- Maintenance: Easy to understand what can fail and why
Common Error Handling Patterns
- Check and Handle: Immediate error handling at call site
- Check and Return: Propagate errors up the call stack
- Check and Wrap: Add context while propagating
- Check and Log: Log errors for debugging while continuing
- Error Accumulation: Collect multiple errors before handling
Practical Applications
Error handling is essential for:
- File Operations: Handling missing files, permission errors
- Network Operations: Timeouts, connection failures, protocol errors
- Database Operations: Connection issues, constraint violations, transaction failures
- User Input Validation: Format errors, business rule violations
- External API Calls: Service unavailable, rate limiting, authentication failures
Common Misconceptions
-
Misconception: Go’s error handling is verbose and cumbersome Reality: Explicit handling leads to more robust and maintainable code
-
Misconception: You should never use panic in Go Reality: Panic is appropriate for programming errors and impossible conditions
-
Misconception: All errors should be handled at the lowest level Reality: Often better to propagate errors with context to appropriate handling level
5. Live Coding Demonstration (20-25 minutes)
Example 1: Basic Error Handling Patterns
package main
import (
"errors"
"fmt"
"strconv"
"strings"
)
// Basic error creation and handling
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
// Error handling with multiple return values
func parseAndDivide(aStr, bStr string) (float64, error) {
a, err := strconv.ParseFloat(aStr, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse first number '%s': %w", aStr, err)
}
b, err := strconv.ParseFloat(bStr, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse second number '%s': %w", bStr, err)
}
result, err := divide(a, b)
if err != nil {
return 0, fmt.Errorf("division failed: %w", err)
}
return result, nil
}
// Sentinel errors for specific conditions
var (
ErrEmptyInput = errors.New("input cannot be empty")
ErrInvalidFormat = errors.New("invalid input format")
ErrOutOfRange = errors.New("value out of acceptable range")
)
// Function using sentinel errors
func validateInput(input string) error {
input = strings.TrimSpace(input)
if input == "" {
return ErrEmptyInput
}
if !strings.Contains(input, "=") {
return ErrInvalidFormat
}
parts := strings.Split(input, "=")
if len(parts) != 2 {
return ErrInvalidFormat
}
value, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return fmt.Errorf("invalid number in input: %w", err)
}
if value < 0 || value > 100 {
return ErrOutOfRange
}
return nil
}
// Error checking patterns
func demonstrateErrorPatterns() {
fmt.Println("=== Basic Error Handling Patterns ===")
// Pattern 1: Check and handle immediately
result, err := divide(10, 2)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("10 / 2 = %.2f\n", result)
// Pattern 2: Check and return (propagate)
result, err = parseAndDivide("10", "0")
if err != nil {
fmt.Printf("Parse and divide error: %v\n", err)
} else {
fmt.Printf("Result: %.2f\n", result)
}
// Pattern 3: Multiple error checks
inputs := []string{"", "invalid", "key=150", "valid=50"}
for _, input := range inputs {
err := validateInput(input)
if err != nil {
// Check for specific error types
switch {
case errors.Is(err, ErrEmptyInput):
fmt.Printf("Input '%s': Please provide input\n", input)
case errors.Is(err, ErrInvalidFormat):
fmt.Printf("Input '%s': Expected format 'key=value'\n", input)
case errors.Is(err, ErrOutOfRange):
fmt.Printf("Input '%s': Value must be between 0 and 100\n", input)
default:
fmt.Printf("Input '%s': Unexpected error: %v\n", input, err)
}
} else {
fmt.Printf("Input '%s': Valid\n", input)
}
}
// Pattern 4: Error accumulation
fmt.Println("\n--- Error Accumulation ---")
var allErrors []error
testInputs := []string{"", "bad=200", "good=50"}
for _, input := range testInputs {
if err := validateInput(input); err != nil {
allErrors = append(allErrors, fmt.Errorf("input '%s': %w", input, err))
}
}
if len(allErrors) > 0 {
fmt.Printf("Found %d validation errors:\n", len(allErrors))
for i, err := range allErrors {
fmt.Printf(" %d. %v\n", i+1, err)
}
} else {
fmt.Println("All inputs valid")
}
}
func main() {
demonstrateErrorPatterns()
}
Explanation:
- Lines 8-12: Basic error creation with
errors.New() - Lines 15-28: Error propagation with context using
fmt.Errorf() - Lines 31-35: Sentinel errors for common conditions
- Lines 65-70: Pattern of checking and handling errors immediately
- Lines 73-79: Error propagation pattern
- Lines 82-98: Using
errors.Is()to check for specific error types
Example 2: Custom Error Types
package main
import (
"fmt"
"net/http"
"time"
)
// Custom error type with additional context
type ValidationError struct {
Field string
Value interface{}
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for field '%s' with value '%v': %s",
e.Field, e.Value, e.Message)
}
// Network error with retry information
type NetworkError struct {
Operation string
URL string
Attempt int
Cause error
Timestamp time.Time
}
func (e *NetworkError) Error() string {
return fmt.Sprintf("network operation '%s' failed on attempt %d for URL '%s' at %s: %v",
e.Operation, e.Attempt, e.URL, e.Timestamp.Format(time.RFC3339), e.Cause)
}
func (e *NetworkError) Unwrap() error {
return e.Cause
}
// Temporary error interface
type TemporaryError interface {
error
Temporary() bool
}
func (e *NetworkError) Temporary() bool {
return e.Attempt < 3 // Temporary if we haven't exceeded retry limit
}
// Business logic error
type BusinessError struct {
Code string
Message string
Details map[string]interface{}
}
func (e *BusinessError) Error() string {
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func (e *BusinessError) GetCode() string {
return e.Code
}
func (e *BusinessError) GetDetails() map[string]interface{} {
return e.Details
}
// User registration with custom error types
type User struct {
ID int
Username string
Email string
Age int
}
func validateUser(user User) error {
if user.Username == "" {
return &ValidationError{
Field: "username",
Value: user.Username,
Message: "username cannot be empty",
}
}
if len(user.Username) < 3 {
return &ValidationError{
Field: "username",
Value: user.Username,
Message: "username must be at least 3 characters",
}
}
if user.Age < 0 || user.Age > 150 {
return &ValidationError{
Field: "age",
Value: user.Age,
Message: "age must be between 0 and 150",
}
}
// Email validation (simplified)
if user.Email == "" || !contains(user.Email, "@") {
return &ValidationError{
Field: "email",
Value: user.Email,
Message: "email must be a valid email address",
}
}
return nil
}
func contains(s, substr string) bool {
return len(s) >= len(substr) &&
len(substr) > 0 &&
findIndex(s, substr) >= 0
}
func findIndex(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
// Business logic with custom errors
func createUser(user User) error {
// Validate input
if err := validateUser(user); err != nil {
return err // Propagate validation error
}
// Check business rules
if user.Username == "admin" || user.Username == "root" {
return &BusinessError{
Code: "RESERVED_USERNAME",
Message: "Username is reserved and cannot be used",
Details: map[string]interface{}{
"attempted_username": user.Username,
"suggestions": []string{user.Username + "1", user.Username + "_user"},
},
}
}
// Simulate database check
if user.ID == 999 {
return &BusinessError{
Code: "USER_EXISTS",
Message: "User with this ID already exists",
Details: map[string]interface{}{
"existing_id": user.ID,
},
}
}
// Simulate successful creation
fmt.Printf("User created successfully: %+v\n", user)
return nil
}
// Network operation with custom error
func fetchUserData(userID int) (*User, error) {
// Simulate network operation
if userID == 404 {
return nil, &NetworkError{
Operation: "GET",
URL: fmt.Sprintf("/api/users/%d", userID),
Attempt: 1,
Cause: fmt.Errorf("HTTP %d: Not Found", http.StatusNotFound),
Timestamp: time.Now(),
}
}
if userID == 500 {
return nil, &NetworkError{
Operation: "GET",
URL: fmt.Sprintf("/api/users/%d", userID),
Attempt: 3,
Cause: fmt.Errorf("HTTP %d: Internal Server Error", http.StatusInternalServerError),
Timestamp: time.Now(),
}
}
// Simulate successful fetch
return &User{
ID: userID,
Username: fmt.Sprintf("user%d", userID),
Email: fmt.Sprintf("user%d@example.com", userID),
Age: 25,
}, nil
}
func main() {
fmt.Println("=== Custom Error Types Demo ===")
// Test validation errors
invalidUsers := []User{
{Username: "", Email: "test@example.com", Age: 25},
{Username: "ab", Email: "test@example.com", Age: 25},
{Username: "validuser", Email: "invalid-email", Age: 25},
{Username: "validuser", Email: "test@example.com", Age: -5},
}
fmt.Println("--- Validation Error Testing ---")
for i, user := range invalidUsers {
err := createUser(user)
if err != nil {
// Type assertion to get specific error information
if validationErr, ok := err.(*ValidationError); ok {
fmt.Printf("Test %d - Validation Error:\n", i+1)
fmt.Printf(" Field: %s\n", validationErr.Field)
fmt.Printf(" Value: %v\n", validationErr.Value)
fmt.Printf(" Message: %s\n", validationErr.Message)
} else {
fmt.Printf("Test %d - Other Error: %v\n", i+1, err)
}
}
fmt.Println()
}
// Test business errors
fmt.Println("--- Business Error Testing ---")
businessTestUsers := []User{
{ID: 1, Username: "admin", Email: "admin@example.com", Age: 30},
{ID: 999, Username: "duplicate", Email: "dup@example.com", Age: 25},
{ID: 1, Username: "validuser", Email: "valid@example.com", Age: 25},
}
for i, user := range businessTestUsers {
err := createUser(user)
if err != nil {
if businessErr, ok := err.(*BusinessError); ok {
fmt.Printf("Business Error %d:\n", i+1)
fmt.Printf(" Code: %s\n", businessErr.GetCode())
fmt.Printf(" Message: %s\n", businessErr.Message)
fmt.Printf(" Details: %+v\n", businessErr.GetDetails())
} else {
fmt.Printf("Other Error %d: %v\n", i+1, err)
}
}
fmt.Println()
}
// Test network errors
fmt.Println("--- Network Error Testing ---")
userIDs := []int{1, 404, 500}
for _, userID := range userIDs {
user, err := fetchUserData(userID)
if err != nil {
fmt.Printf("Fetch User %d Error:\n", userID)
// Check if it's a temporary error
if tempErr, ok := err.(TemporaryError); ok {
fmt.Printf(" Temporary: %t\n", tempErr.Temporary())
if tempErr.Temporary() {
fmt.Println(" -> Should retry operation")
} else {
fmt.Println(" -> Should not retry operation")
}
}
// Type assertion for network error details
if netErr, ok := err.(*NetworkError); ok {
fmt.Printf(" Operation: %s\n", netErr.Operation)
fmt.Printf(" URL: %s\n", netErr.URL)
fmt.Printf(" Attempt: %d\n", netErr.Attempt)
fmt.Printf(" Timestamp: %s\n", netErr.Timestamp.Format(time.RFC3339))
fmt.Printf(" Underlying cause: %v\n", netErr.Cause)
}
} else {
fmt.Printf("User %d fetched successfully: %+v\n", userID, *user)
}
fmt.Println()
}
}
Example 3: Error Wrapping and Context
package main
import (
"errors"
"fmt"
"strconv"
"strings"
)
// Simulated data processing pipeline with error wrapping
type DataProcessor struct {
Name string
}
// Custom error for data processing
type ProcessingError struct {
Stage string
Input string
Cause error
Context map[string]interface{}
}
func (e *ProcessingError) Error() string {
return fmt.Sprintf("processing failed at stage '%s' with input '%s': %v",
e.Stage, e.Input, e.Cause)
}
func (e *ProcessingError) Unwrap() error {
return e.Cause
}
// Data validation stage
func validateData(data string) error {
if data == "" {
return errors.New("data cannot be empty")
}
if len(data) < 3 {
return errors.New("data too short, minimum 3 characters required")
}
return nil
}
// Data transformation stage
func transformData(data string) (string, error) {
if err := validateData(data); err != nil {
return "", fmt.Errorf("validation failed: %w", err)
}
// Simulate transformation (convert to uppercase and add prefix)
if strings.Contains(data, "error") {
return "", errors.New("data contains forbidden keyword 'error'")
}
transformed := "PROCESSED_" + strings.ToUpper(data)
return transformed, nil
}
// Data parsing stage
func parseData(data string) (int, error) {
transformed, err := transformData(data)
if err != nil {
return 0, fmt.Errorf("transformation failed: %w", err)
}
// Extract number from transformed data
parts := strings.Split(transformed, "_")
if len(parts) < 2 {
return 0, errors.New("no parseable number found in transformed data")
}
// Try to parse the last part as number
lastPart := parts[len(parts)-1]
number, err := strconv.Atoi(lastPart)
if err != nil {
return 0, fmt.Errorf("failed to parse number from '%s': %w", lastPart, err)
}
return number, nil
}
// Main processing method with comprehensive error wrapping
func (dp *DataProcessor) ProcessData(input string) (int, error) {
result, err := parseData(input)
if err != nil {
// Wrap with processing context
return 0, &ProcessingError{
Stage: "parse",
Input: input,
Cause: err,
Context: map[string]interface{}{
"processor": dp.Name,
"timestamp": "2025-07-30T10:00:00Z",
"inputLength": len(input),
},
}
}
// Additional business logic validation
if result < 0 {
return 0, &ProcessingError{
Stage: "business_validation",
Input: input,
Cause: errors.New("negative numbers not allowed"),
Context: map[string]interface{}{
"processor": dp.Name,
"parsedValue": result,
},
}
}
return result, nil
}
// Error chain analysis
func analyzeError(err error) {
fmt.Println("=== Error Chain Analysis ===")
// Walk through the error chain
current := err
level := 0
for current != nil {
fmt.Printf("Level %d: %v\n", level, current)
// Check for specific error types
var processingErr *ProcessingError
if errors.As(current, &processingErr) {
fmt.Printf(" -> Processing Error Details:\n")
fmt.Printf(" Stage: %s\n", processingErr.Stage)
fmt.Printf(" Input: %s\n", processingErr.Input)
fmt.Printf(" Context: %+v\n", processingErr.Context)
}
// Unwrap to next level
current = errors.Unwrap(current)
level++
if level > 10 { // Prevent infinite loops
fmt.Println(" -> Error chain too deep, stopping analysis")
break
}
}
}
// Batch processing with error collection
func batchProcess(processor *DataProcessor, inputs []string) map[string]interface{} {
results := make(map[string]interface{})
var allErrors []error
for _, input := range inputs {
result, err := processor.ProcessData(input)
if err != nil {
results[input] = map[string]interface{}{
"success": false,
"error": err.Error(),
}
allErrors = append(allErrors, fmt.Errorf("input '%s': %w", input, err))
} else {
results[input] = map[string]interface{}{
"success": true,
"result": result,
}
}
}
// Add summary information
results["_summary"] = map[string]interface{}{
"total_inputs": len(inputs),
"successful": len(inputs) - len(allErrors),
"failed": len(allErrors),
"success_rate": float64(len(inputs)-len(allErrors)) / float64(len(inputs)) * 100,
"first_error": nil,
}
if len(allErrors) > 0 {
results["_summary"].(map[string]interface{})["first_error"] = allErrors[0].Error()
}
return results
}
// Error recovery demonstration
func demonstrateErrorRecovery() {
fmt.Println("\n=== Error Recovery Patterns ===")
processor := &DataProcessor{Name: "MainProcessor"}
// Test different recovery strategies
inputs := []string{"123", "error_input", "", "abc", "456"}
for _, input := range inputs {
result, err := processor.ProcessData(input)
if err != nil {
fmt.Printf("Processing '%s' failed: %v\n", input, err)
// Recovery strategy 1: Use default value
defaultResult := 0
fmt.Printf(" -> Using default value: %d\n", defaultResult)
// Recovery strategy 2: Skip and continue
fmt.Printf(" -> Skipping this input and continuing\n")
// Recovery strategy 3: Retry with modified input
if input == "" {
retryInput := "default"
fmt.Printf(" -> Retrying with input: '%s'\n", retryInput)
retryResult, retryErr := processor.ProcessData(retryInput)
if retryErr == nil {
fmt.Printf(" -> Retry successful: %d\n", retryResult)
} else {
fmt.Printf(" -> Retry also failed: %v\n", retryErr)
}
}
} else {
fmt.Printf("Processing '%s' successful: %d\n", input, result)
}
fmt.Println()
}
}
func main() {
processor := &DataProcessor{Name: "DemoProcessor"}
// Test successful case
fmt.Println("=== Successful Processing ===")
result, err := processor.ProcessData("123")
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Success: %d\n", result)
}
// Test error cases with detailed analysis
fmt.Println("\n=== Error Cases ===")
errorInputs := []string{"", "xy", "error_test", "abc"}
for _, input := range errorInputs {
fmt.Printf("\nProcessing input: '%s'\n", input)
_, err := processor.ProcessData(input)
if err != nil {
analyzeError(err)
// Check for specific error conditions
var processingErr *ProcessingError
if errors.As(err, &processingErr) {
fmt.Printf("Found ProcessingError at stage: %s\n", processingErr.Stage)
}
// Check if error chain contains validation error
if strings.Contains(err.Error(), "validation failed") {
fmt.Println("This is a validation-related error")
}
}
fmt.Println(strings.Repeat("-", 50))
}
// Batch processing demonstration
fmt.Println("\n=== Batch Processing ===")
batchInputs := []string{"123", "456", "", "error_input", "789", "abc"}
results := batchProcess(processor, batchInputs)
for key, value := range results {
if key == "_summary" {
fmt.Printf("Summary: %+v\n", value)
} else {
fmt.Printf("Input '%s': %+v\n", key, value)
}
}
// Error recovery demonstration
demonstrateErrorRecovery()
}
Example 4: Panic and Recovery
package main
import (
"fmt"
"runtime"
"time"
)
// Demonstration of panic and recovery patterns
type SafeCalculator struct {
Name string
}
// Function that might panic
func riskyDivision(a, b float64) float64 {
if b == 0 {
panic(fmt.Sprintf("cannot divide %.2f by zero", a))
}
return a / b
}
// Function with recovery
func (sc *SafeCalculator) SafeDivide(a, b float64) (result float64, err error) {
// Set up recovery
defer func() {
if r := recover(); r != nil {
// Log panic for debugging
fmt.Printf("[%s] Panic recovered: %v\n", sc.Name, r)
// Convert panic to error
err = fmt.Errorf("division panic: %v", r)
result = 0
}
}()
// Call potentially panicking function
result = riskyDivision(a, b)
return result, nil
}
// Complex operation with nested panic recovery
func (sc *SafeCalculator) ComplexCalculation(values []float64) (results []float64, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("complex calculation failed: %v", r)
// Log stack trace for debugging
buf := make([]byte, 1024)
n := runtime.Stack(buf, false)
fmt.Printf("Stack trace:\n%s\n", buf[:n])
}
}()
results = make([]float64, 0, len(values))
for i, val := range values {
// Each operation is potentially risky
intermediate := riskyDivision(val, float64(i)) // Will panic when i=0
results = append(results, intermediate)
}
return results, nil
}
// Graceful shutdown with panic recovery
type Server struct {
Name string
IsRunning bool
}
func (s *Server) Start() error {
s.IsRunning = true
fmt.Printf("Server %s starting...\n", s.Name)
// Set up panic recovery for the entire server
defer func() {
if r := recover(); r != nil {
fmt.Printf("Server %s crashed: %v\n", s.Name, r)
s.gracefulShutdown()
}
}()
// Simulate server work that might panic
s.handleRequests()
return nil
}
func (s *Server) handleRequests() {
for i := 0; i < 5; i++ {
fmt.Printf("Handling request %d\n", i+1)
time.Sleep(100 * time.Millisecond)
// Simulate random panic
if i == 3 {
panic("simulated server error: database connection lost")
}
}
fmt.Printf("Server %s completed all requests\n", s.Name)
}
func (s *Server) gracefulShutdown() {
fmt.Printf("Performing graceful shutdown for server %s\n", s.Name)
s.IsRunning = false
// Cleanup operations
fmt.Println("- Closing database connections")
fmt.Println("- Saving pending data")
fmt.Println("- Notifying monitoring systems")
fmt.Println("Graceful shutdown completed")
}
// Goroutine panic handling
func riskyGoroutine(id int, resultChan chan<- string, errorChan chan<- error) {
defer func() {
if r := recover(); r != nil {
errorChan <- fmt.Errorf("goroutine %d panicked: %v", id, r)
}
}()
// Simulate some work
time.Sleep(time.Duration(id*100) * time.Millisecond)
// Some goroutines will panic
if id%3 == 0 {
panic(fmt.Sprintf("goroutine %d encountered fatal error", id))
}
resultChan <- fmt.Sprintf("goroutine %d completed successfully", id)
}
func demonstrateGoroutinePanicHandling() {
fmt.Println("\n=== Goroutine Panic Handling ===")
numGoroutines := 6
resultChan := make(chan string, numGoroutines)
errorChan := make(chan error, numGoroutines)
// Start goroutines
for i := 1; i <= numGoroutines; i++ {
go riskyGoroutine(i, resultChan, errorChan)
}
// Collect results and errors
var results []string
var errors []error
for i := 0; i < numGoroutines; i++ {
select {
case result := <-resultChan:
results = append(results, result)
case err := <-errorChan:
errors = append(errors, err)
case <-time.After(1 * time.Second):
fmt.Printf("Timeout waiting for goroutine %d\n", i+1)
}
}
fmt.Printf("Successful results: %d\n", len(results))
for _, result := range results {
fmt.Printf(" - %s\n", result)
}
fmt.Printf("Errors encountered: %d\n", len(errors))
for _, err := range errors {
fmt.Printf(" - %v\n", err)
}
}
// Recovery middleware pattern
type Handler func() error
func withRecovery(handler Handler, name string) error {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Handler '%s' recovered from panic: %v\n", name, r)
}
}()
return handler()
}
func panickyHandler() error {
fmt.Println("Doing some work...")
panic("something went terribly wrong")
return nil
}
func normalHandler() error {
fmt.Println("Normal handler executed successfully")
return nil
}
func main() {
fmt.Println("=== Panic and Recovery Demonstration ===")
// Basic panic recovery
calculator := &SafeCalculator{Name: "MainCalc"}
fmt.Println("--- Safe Division Tests ---")
testCases := [][]float64{
{10, 2}, // Normal case
{15, 0}, // Division by zero (will panic)
{20, 4}, // Normal case
}
for _, test := range testCases {
result, err := calculator.SafeDivide(test[0], test[1])
if err != nil {
fmt.Printf("Error: %.2f / %.2f -> %v\n", test[0], test[1], err)
} else {
fmt.Printf("Success: %.2f / %.2f = %.2f\n", test[0], test[1], result)
}
}
// Complex calculation with panic
fmt.Println("\n--- Complex Calculation Test ---")
values := []float64{10, 20, 30, 40} // Will panic on first iteration (divide by 0)
results, err := calculator.ComplexCalculation(values)
if err != nil {
fmt.Printf("Complex calculation failed: %v\n", err)
} else {
fmt.Printf("Complex calculation results: %v\n", results)
}
// Server panic recovery
fmt.Println("\n--- Server Panic Recovery ---")
server := &Server{Name: "WebServer"}
err := server.Start()
if err != nil {
fmt.Printf("Server error: %v\n", err)
}
// Goroutine panic handling
demonstrateGoroutinePanicHandling()
// Recovery middleware pattern
fmt.Println("\n--- Recovery Middleware Pattern ---")
handlers := map[string]Handler{
"normal": normalHandler,
"panicky": panickyHandler,
}
for name, handler := range handlers {
fmt.Printf("Executing handler '%s':\n", name)
err := withRecovery(handler, name)
if err != nil {
fmt.Printf("Handler returned error: %v\n", err)
}
fmt.Println()
}
// Best practices summary
fmt.Println("=== Panic and Recovery Best Practices ===")
fmt.Println("1. Use panic only for programming errors or impossible conditions")
fmt.Println("2. Always use defer for recovery to ensure cleanup")
fmt.Println("3. Convert panics to errors at package boundaries")
fmt.Println("4. Log panics with stack traces for debugging")
fmt.Println("5. Implement graceful shutdown in long-running processes")
fmt.Println("6. Handle panics in goroutines to prevent program termination")
}
6. Hands-On Practice (30-40 minutes)
Exercise 1: File Processing with Error Handling (10 minutes)
Objective: Build a robust file processor with comprehensive error handling
Instructions:
- Create a file reader that handles missing files, permission errors
- Implement data validation with custom error types
- Add error wrapping with context
- Include recovery mechanisms for critical errors
Starter Code:
package main
import (
"fmt"
"os"
)
type FileProcessor struct {
Name string
}
// TODO: Implement ProcessFile method with error handling
// TODO: Create custom error types for different failure modes
// TODO: Add error wrapping and context
// TODO: Handle panics gracefully
func main() {
processor := &FileProcessor{Name: "DataProcessor"}
// Test with various file scenarios
files := []string{"existing.txt", "missing.txt", "restricted.txt"}
for _, filename := range files {
fmt.Printf("Processing %s...\n", filename)
// Call your ProcessFile method
}
}
Solution:
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
type FileProcessor struct {
Name string
}
// Custom error types
type FileError struct {
Operation string
Filename string
Cause error
}
func (e *FileError) Error() string {
return fmt.Sprintf("file operation '%s' failed for '%s': %v",
e.Operation, e.Filename, e.Cause)
}
func (e *FileError) Unwrap() error {
return e.Cause
}
type ValidationError struct {
Line int
Content string
Expected string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed at line %d: expected %s, got '%s'",
e.Line, e.Expected, e.Content)
}
// ProcessFile with comprehensive error handling
func (fp *FileProcessor) ProcessFile(filename string) ([]int, error) {
// Set up panic recovery
defer func() {
if r := recover(); r != nil {
fmt.Printf("[%s] Recovered from panic while processing %s: %v\n",
fp.Name, filename, r)
}
}()
// Open file with error wrapping
file, err := os.Open(filename)
if err != nil {
return nil, &FileError{
Operation: "open",
Filename: filename,
Cause: err,
}
}
defer file.Close()
var numbers []int
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue // Skip empty lines
}
number, err := strconv.Atoi(line)
if err != nil {
return nil, &ValidationError{
Line: lineNum,
Content: line,
Expected: "integer",
}
}
numbers = append(numbers, number)
}
if err := scanner.Err(); err != nil {
return nil, &FileError{
Operation: "read",
Filename: filename,
Cause: err,
}
}
return numbers, nil
}
func main() {
processor := &FileProcessor{Name: "DataProcessor"}
// Create test files
testFiles := map[string]string{
"numbers.txt": "1\n2\n3\n4\n5",
"mixed.txt": "1\nabc\n3",
"empty.txt": "",
}
for filename, content := range testFiles {
file, err := os.Create(filename)
if err != nil {
fmt.Printf("Failed to create test file %s: %v\n", filename, err)
continue
}
file.WriteString(content)
file.Close()
}
// Test processing
files := []string{"numbers.txt", "mixed.txt", "empty.txt", "missing.txt"}
for _, filename := range files {
fmt.Printf("Processing %s...\n", filename)
numbers, err := processor.ProcessFile(filename)
if err != nil {
// Handle different error types
var fileErr *FileError
var validationErr *ValidationError
switch {
case errors.As(err, &fileErr):
fmt.Printf(" File Error: %s\n", err)
if os.IsNotExist(fileErr.Cause) {
fmt.Printf(" -> File doesn't exist, creating default...\n")
// Recovery: create default file
}
case errors.As(err, &validationErr):
fmt.Printf(" Validation Error: %s\n", err)
fmt.Printf(" -> Skipping invalid line and continuing...\n")
default:
fmt.Printf(" Unknown Error: %s\n", err)
}
} else {
fmt.Printf(" Success: %v\n", numbers)
}
fmt.Println()
}
// Cleanup
for filename := range testFiles {
os.Remove(filename)
}
}
Exercise 2: HTTP Client with Retry Logic (15 minutes)
Objective: Implement an HTTP client with sophisticated error handling and retry mechanisms
Requirements:
- Handle different types of HTTP errors (timeout, 4xx, 5xx)
- Implement exponential backoff for retries
- Create custom error types with retry information
- Add circuit breaker pattern for repeated failures
Instructions:
- Design error types for different HTTP failure scenarios
- Implement retry logic with exponential backoff
- Add circuit breaker to prevent cascading failures
- Include comprehensive logging and monitoring
Exercise 3: Concurrent Error Aggregation (15 minutes)
Objective: Build a system that handles errors from multiple concurrent operations
Challenge: Create a worker pool that processes tasks concurrently and aggregates all errors
Requirements:
- Process multiple tasks concurrently using goroutines
- Collect and categorize errors from all workers
- Implement timeout handling for long-running tasks
- Provide detailed error reporting and statistics
Extension Ideas:
- Add different error recovery strategies per error type
- Implement error rate limiting to prevent spam
- Create error dashboards with metrics
- Add integration with external monitoring systems
7. Common Pitfalls and Troubleshooting (10 minutes)
Frequent Mistakes
-
Problem: Ignoring errors or using
_to discard them Cause: Laziness or not understanding the importance of error handling Solution: Always handle errors appropriately - check, log, or propagate -
Problem: Creating error strings that are hard to parse programmatically Cause: Using only string errors without structure Solution: Use custom error types with structured information
-
Problem: Losing error context when propagating up the call stack Cause: Not using error wrapping to preserve context Solution: Use
fmt.Errorf()with%wverb to wrap errors -
Problem: Overusing panic for normal error conditions Cause: Misunderstanding when panic is appropriate Solution: Use panic only for programming errors and impossible conditions
Debugging Tips
- Error Logging: Log errors with sufficient context (timestamps, user IDs, request IDs)
- Error Metrics: Track error rates and types for monitoring
- Error Tracing: Use correlation IDs to trace errors across services
- Stack Traces: Include stack traces for debugging but not for user-facing errors
8. Best Practices and Professional Tips (5 minutes)
Industry Standards
- Error Messages: Clear, actionable, and user-appropriate
- Error Codes: Consistent error codes for API responses
- Error Documentation: Document all possible errors for public APIs
- Error Testing: Write tests for all error conditions
Code Organization
// Good: Structured error with context
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
// Good: Error checking pattern
result, err := operation()
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
Performance Considerations
- Error Allocation: Pre-allocate common errors to reduce GC pressure
- Error Strings: Use error variables instead of creating new strings each time
- Error Propagation: Consider performance impact of deep error wrapping
Next Steps in Your Learning Journey
- Context Package: Learn timeout and cancellation patterns
- Logging Libraries: Structured logging with error correlation
- Monitoring: Error tracking and alerting systems
- Distributed Tracing: Error tracking across microservices
9. Summary and Review (5 minutes)
Key Takeaways
- Explicit Errors: Go’s error handling makes failure cases visible and manageable
- Custom Types: Rich error types provide better context and handling options
- Error Wrapping: Preserve error chains while adding context
- Panic Recovery: Use for exceptional cases with proper recovery mechanisms
Quick Review Questions
- When should you use custom error types vs simple string errors?
- How does error wrapping help with debugging and error handling?
- What’s the difference between panic/recover and normal error handling?
- How can you check if an error is of a specific type or contains another error?
Answers:
- Use custom types when you need structured information or type-specific handling
- Wrapping preserves error chains while adding context at each layer
- Panic/recover is for exceptional cases; normal errors are for expected failures
- Use
errors.Is()anderrors.As()for type checking and unwrapping
For Instructors
Teaching Tips
- Demonstrate real-world scenarios where poor error handling causes production issues
- Show debugging sessions where good error messages save time
- Compare with other languages to highlight Go’s approach
- Emphasize testing error conditions as much as success paths
Assessment Ideas
- Error handling refactoring: Give code with poor error handling to improve
- Error type design: Create custom errors for specific business domains
- Debugging exercises: Trace errors through complex call stacks
- Production scenarios: Handle cascading failures and recovery
Adaptation Notes
- Beginner Focus: Start with basic error checking patterns
- Advanced Students: Explore error monitoring and distributed tracing
- Coming from Exceptions: Highlight differences and advantages
- Time Management: Error handling concepts build on each other progressively