Lua - Error Handling

Error Handling

Learn comprehensive error handling in Lua using pcall, xpcall, and custom error handling patterns for building robust, fault-tolerant applications.

Estimated time: 25-30 minutes

Learning Objectives

  • Understand Lua's error handling mechanisms
  • Use pcall and xpcall for protected function calls
  • Create custom error types and handlers
  • Implement error recovery strategies
  • Build resilient applications with proper error management

Basic Error Concepts

Lua uses exceptions for error handling. When an error occurs, execution stops and an error message is generated.

-- Basic error demonstration
local function demonstrate_errors()
  print("1. Normal execution")
  
  -- This will cause an error
  -- error("Something went wrong!")  -- Uncomment to see error
  
  print("2. This line executes if no error above")
  
  -- Trying to call nil as function
  local func = nil
  -- func()  -- Uncomment to see "attempt to call a nil value" error
  
  -- Division by zero (actually returns inf in Lua)
  print("3. Division by zero:", 10/0)
  
  -- Accessing non-existent table field (returns nil, no error)
  local t = {}
  print("4. Non-existent field:", t.missing)
  
  -- This WILL cause an error - indexing nil
  -- print("5. Indexing nil:", t.missing.field)  -- Uncomment to see error
end

demonstrate_errors()

Expected Output:

1. Normal execution
2. This line executes if no error above
3. Division by zero: inf
4. Non-existent field: nil

Protected Calls with pcall

pcall (protected call) allows you to catch errors without stopping program execution.

-- Using pcall for error handling
local function pcall_examples()
  -- Function that might fail
  local function risky_division(a, b)
    if b == 0 then
      error("Cannot divide by zero")
    end
    return a / b
  end
  
  -- Safe call using pcall
  local function safe_divide(a, b)
    local success, result = pcall(risky_division, a, b)
    
    if success then
      print("Division successful:", result)
      return result
    else
      print("Division failed:", result)  -- result contains error message
      return nil
    end
  end
  
  print("=== pcall Examples ===")
  
  -- Successful call
  safe_divide(10, 2)
  
  -- Failed call
  safe_divide(10, 0)
  
  -- Multiple operations with error handling
  local function process_data(data)
    -- Validate input
    if type(data) ~= "table" then
      error("Expected table, got " .. type(data))
    end
    
    -- Process each item
    local results = {}
    for i, value in ipairs(data) do
      if type(value) ~= "number" then
        error("Item " .. i .. " is not a number: " .. tostring(value))
      end
      table.insert(results, value * 2)
    end
    
    return results
  end
  
  -- Test with valid data
  local success, result = pcall(process_data, {1, 2, 3, 4})
  if success then
    print("Processing successful:", table.concat(result, ", "))
  else
    print("Processing failed:", result)
  end
  
  -- Test with invalid data
  success, result = pcall(process_data, {1, "invalid", 3})
  if success then
    print("Processing successful:", table.concat(result, ", "))
  else
    print("Processing failed:", result)
  end
  
  -- Test with wrong type
  success, result = pcall(process_data, "not a table")
  if success then
    print("Processing successful:", table.concat(result, ", "))
  else
    print("Processing failed:", result)
  end
end

pcall_examples()

Expected Output:

=== pcall Examples ===
Division successful: 5.0
Division failed: Cannot divide by zero
Processing successful: 2, 4, 6, 8
Processing failed: Item 2 is not a number: invalid
Processing failed: Expected table, got string

Extended Error Handling with xpcall

xpcall allows you to specify a custom error handler for more detailed error information.

-- Using xpcall with custom error handlers
local function xpcall_examples()
  -- Error handler that provides stack trace
  local function error_handler(err)
    local trace = debug.traceback()
    return {
      message = err,
      trace = trace,
      timestamp = os.date("%Y-%m-%d %H:%M:%S")
    }
  end
  
  -- Function that might fail deeply nested
  local function level3()
    error("Deep error occurred")
  end
  
  local function level2()
    level3()
  end
  
  local function level1()
    level2()
  end
  
  print("=== xpcall with Custom Error Handler ===")
  
  -- Using xpcall with error handler
  local success, result = xpcall(level1, error_handler)
  
  if success then
    print("Function executed successfully")
  else
    print("Error occurred:")
    print("Message:", result.message)
    print("Timestamp:", result.timestamp)
    print("Stack trace:")
    print(result.trace)
  end
  
  -- Simplified error handler for logging
  local function simple_error_handler(err)
    local info = debug.getinfo(2, "Sl")
    return string.format("[%s:%d] %s", 
                        info.short_src or "unknown", 
                        info.currentline or 0, 
                        err)
  end
  
  -- File operation with error handling
  local function read_config_file(filename)
    local file = io.open(filename, "r")
    if not file then
      error("Could not open config file: " .. filename)
    end
    
    local content = file:read("*a")
    file:close()
    
    -- Simulate parsing error
    if content:find("invalid") then
      error("Invalid configuration detected")
    end
    
    return content
  end
  
  print("\n=== File Operation Error Handling ===")
  
  -- Try to read non-existent file
  success, result = xpcall(function()
    return read_config_file("nonexistent.txt")
  end, simple_error_handler)
  
  if success then
    print("Config loaded successfully")
  else
    print("Failed to load config:", result)
  end
end

xpcall_examples()

Expected Output:

=== xpcall with Custom Error Handler ===
Error occurred:
Message: Deep error occurred
Timestamp: 2024-01-15 14:30:45
Stack trace:
stack traceback:
	[string "..."]:6: in function 'level3'
	[string "..."]:10: in function 'level2'
	[string "..."]:14: in function 'level1'
	...

=== File Operation Error Handling ===
Failed to load config: [string "..."]:45] Could not open config file: nonexistent.txt

Custom Error Types and Classes

Create structured error handling with custom error types for different failure scenarios.

-- Custom error type system
local ErrorType = {
  VALIDATION = "ValidationError",
  FILE_IO = "FileIOError",
  NETWORK = "NetworkError",
  DATABASE = "DatabaseError"
}

-- Error factory
local function create_error(error_type, message, context)
  return {
    type = error_type,
    message = message,
    context = context or {},
    timestamp = os.time(),
    stack_trace = debug.traceback()
  }
end

-- Enhanced error handling class
local ErrorHandler = {}
ErrorHandler.__index = ErrorHandler

function ErrorHandler.new()
  local self = setmetatable({}, ErrorHandler)
  self.error_log = {}
  self.handlers = {}
  return self
end

function ErrorHandler:register_handler(error_type, handler_func)
  self.handlers[error_type] = handler_func
end

function ErrorHandler:handle_error(err)
  -- Log the error
  table.insert(self.error_log, err)
  
  -- Call specific handler if available
  local handler = self.handlers[err.type]
  if handler then
    return handler(err)
  end
  
  -- Default handler
  print(string.format("[%s] %s: %s", 
                     os.date("%H:%M:%S", err.timestamp),
                     err.type, 
                     err.message))
  
  if next(err.context) then
    print("Context:", table.concat(err.context, ", "))
  end
  
  return false -- indicate error was not recovered
end

function ErrorHandler:get_error_count(error_type)
  local count = 0
  for _, err in ipairs(self.error_log) do
    if not error_type or err.type == error_type then
      count = count + 1
    end
  end
  return count
end

-- Example application with structured error handling
local function demo_custom_error_handling()
  local error_handler = ErrorHandler.new()
  
  -- Register specific error handlers
  error_handler:register_handler(ErrorType.VALIDATION, function(err)
    print("VALIDATION ERROR: " .. err.message)
    if err.context.field then
      print("Field: " .. err.context.field)
    end
    return true -- error handled
  end)
  
  error_handler:register_handler(ErrorType.FILE_IO, function(err)
    print("FILE I/O ERROR: " .. err.message)
    if err.context.filename then
      print("Attempting fallback for file: " .. err.context.filename)
      -- Could implement fallback logic here
    end
    return false -- error not fully recovered
  end)
  
  -- Simulated application functions
  local function validate_user_input(user_data)
    if not user_data.name or user_data.name == "" then
      local err = create_error(ErrorType.VALIDATION, 
                              "Name is required", 
                              {field = "name", value = user_data.name})
      error(err)
    end
    
    if not user_data.age or user_data.age < 0 or user_data.age > 150 then
      local err = create_error(ErrorType.VALIDATION, 
                              "Age must be between 0 and 150", 
                              {field = "age", value = user_data.age})
      error(err)
    end
    
    return true
  end
  
  local function save_user_data(user_data, filename)
    local file = io.open(filename, "w")
    if not file then
      local err = create_error(ErrorType.FILE_IO, 
                              "Cannot open file for writing", 
                              {filename = filename})
      error(err)
    end
    
    file:write(string.format("Name: %s\nAge: %d\n", 
                            user_data.name, user_data.age))
    file:close()
    return true
  end
  
  -- Test the error handling system
  local test_cases = {
    {name = "John Doe", age = 25},           -- Valid
    {name = "", age = 30},                   -- Invalid name
    {name = "Jane Smith", age = -5},         -- Invalid age
    {name = "Bob Wilson", age = 35}          -- Valid but will fail on file I/O
  }
  
  print("=== Custom Error Handling Demo ===")
  
  for i, user_data in ipairs(test_cases) do
    print(string.format("\nProcessing user %d: %s", i, user_data.name or "(empty)"))
    
    -- Validation step
    local success, result = xpcall(function()
      return validate_user_input(user_data)
    end, function(err)
      return err
    end)
    
    if success then
      print("Validation passed")
      
      -- File I/O step (will fail for demonstration)
      local filename = i == 4 and "/invalid/path/user.txt" or "user_" .. i .. ".txt"
      success, result = xpcall(function()
        return save_user_data(user_data, filename)
      end, function(err)
        return err
      end)
      
      if success then
        print("User data saved successfully")
      else
        error_handler:handle_error(result)
      end
    else
      error_handler:handle_error(result)
    end
  end
  
  -- Display error statistics
  print(string.format("\n=== Error Statistics ==="))
  print("Total errors:", error_handler:get_error_count())
  print("Validation errors:", error_handler:get_error_count(ErrorType.VALIDATION))
  print("File I/O errors:", error_handler:get_error_count(ErrorType.FILE_IO))
end

demo_custom_error_handling()

Expected Output:

=== Custom Error Handling Demo ===

Processing user 1: John Doe
Validation passed
User data saved successfully

Processing user 2: (empty)
VALIDATION ERROR: Name is required
Field: name

Processing user 3: Jane Smith
VALIDATION ERROR: Age must be between 0 and 150
Field: age

Processing user 4: Bob Wilson
Validation passed
FILE I/O ERROR: Cannot open file for writing
Attempting fallback for file: /invalid/path/user.txt

=== Error Statistics ===
Total errors: 3
Validation errors: 2
File I/O errors: 1

Error Recovery Patterns

Implement common error recovery strategies for resilient applications.

-- Retry mechanism with exponential backoff
local function create_retry_wrapper(max_attempts, base_delay)
  max_attempts = max_attempts or 3
  base_delay = base_delay or 1
  
  return function(func, ...)
    local args = {...}
    
    for attempt = 1, max_attempts do
      local success, result = pcall(func, table.unpack(args))
      
      if success then
        if attempt > 1 then
          print(string.format("Operation succeeded on attempt %d", attempt))
        end
        return result
      end
      
      print(string.format("Attempt %d failed: %s", attempt, result))
      
      if attempt < max_attempts then
        local delay = base_delay * (2 ^ (attempt - 1)) -- exponential backoff
        print(string.format("Retrying in %d seconds...", delay))
        -- In real application, you'd use actual sleep function
        -- os.execute("sleep " .. delay)  -- Unix/Linux
      end
    end
    
    error("All retry attempts failed")
  end
end

-- Circuit breaker pattern
local CircuitBreaker = {}
CircuitBreaker.__index = CircuitBreaker

function CircuitBreaker.new(failure_threshold, recovery_timeout)
  local self = setmetatable({}, CircuitBreaker)
  self.failure_threshold = failure_threshold or 5
  self.recovery_timeout = recovery_timeout or 30
  self.failure_count = 0
  self.last_failure_time = 0
  self.state = "closed" -- closed, open, half-open
  return self
end

function CircuitBreaker:call(func, ...)
  if self.state == "open" then
    if os.time() - self.last_failure_time > self.recovery_timeout then
      self.state = "half-open"
      print("Circuit breaker transitioning to half-open")
    else
      error("Circuit breaker is OPEN - fast failing")
    end
  end
  
  local success, result = pcall(func, ...)
  
  if success then
    if self.state == "half-open" then
      self.state = "closed"
      self.failure_count = 0
      print("Circuit breaker CLOSED - service recovered")
    end
    return result
  else
    self.failure_count = self.failure_count + 1
    self.last_failure_time = os.time()
    
    if self.failure_count >= self.failure_threshold then
      self.state = "open"
      print(string.format("Circuit breaker OPENED after %d failures", 
                         self.failure_count))
    end
    
    error(result)
  end
end

-- Demo error recovery patterns
local function demo_error_recovery()
  print("=== Error Recovery Patterns Demo ===")
  
  -- Unreliable service simulation
  local service_call_count = 0
  local function unreliable_service()
    service_call_count = service_call_count + 1
    
    -- Fail first 2 times, succeed on 3rd
    if service_call_count <= 2 then
      error("Service temporarily unavailable")
    end
    
    return "Service response: Success!"
  end
  
  -- Test retry mechanism
  print("\n--- Retry Mechanism Test ---")
  local retry_func = create_retry_wrapper(4, 1)
  
  local success, result = pcall(retry_func, unreliable_service)
  if success then
    print("Final result:", result)
  else
    print("All retries failed:", result)
  end
  
  -- Test circuit breaker
  print("\n--- Circuit Breaker Test ---")
  local circuit_breaker = CircuitBreaker.new(3, 5)
  
  local function failing_service()
    error("Service is down")
  end
  
  -- Simulate multiple calls to failing service
  for i = 1, 7 do
    local success, result = pcall(function()
      return circuit_breaker:call(failing_service)
    end)
    
    if success then
      print(string.format("Call %d: Success - %s", i, result))
    else
      print(string.format("Call %d: Failed - %s", i, result))
    end
    
    -- Brief pause simulation
    if i == 5 then
      print("--- Waiting for recovery timeout ---")
    end
  end
end

demo_error_recovery()

Expected Output:

=== Error Recovery Patterns Demo ===

--- Retry Mechanism Test ---
Attempt 1 failed: Service temporarily unavailable
Retrying in 1 seconds...
Attempt 2 failed: Service temporarily unavailable
Retrying in 2 seconds...
Operation succeeded on attempt 3
Final result: Service response: Success!

--- Circuit Breaker Test ---
Call 1: Failed - Service is down
Call 2: Failed - Service is down
Call 3: Failed - Service is down
Circuit breaker OPENED after 3 failures
Call 4: Failed - Circuit breaker is OPEN - fast failing
Call 5: Failed - Circuit breaker is OPEN - fast failing
--- Waiting for recovery timeout ---
Call 6: Failed - Circuit breaker is OPEN - fast failing
Call 7: Failed - Circuit breaker is OPEN - fast failing

Common Pitfalls

Error Handling Best Practices

  • Don't ignore errors: Always handle or propagate errors appropriately
  • Use specific error messages: Include context about what went wrong
  • Log errors: Maintain error logs for debugging and monitoring
  • Clean up resources: Ensure files, connections are closed even on errors
  • Fail fast: Don't continue with invalid data

Checks for Understanding

  1. What's the difference between pcall and xpcall?
  2. When should you use error recovery vs. failing fast?
  3. What information should a good error message contain?
  4. How can you prevent resource leaks during error conditions?
Show answers
  1. xpcall allows custom error handler function for enhanced error info
  2. Recovery for transient issues, fail fast for logic errors or invalid data
  3. What happened, where, when, and possibly how to fix it
  4. Use finally-like patterns or ensure cleanup in error handlers

Exercises

  1. Create a robust file downloader with retry logic
  2. Implement a validation framework with custom error types
  3. Build a circuit breaker for database operations