CrackedRuby logo

CrackedRuby

Custom Exception Classes

Overview

Ruby provides a built-in exception hierarchy with StandardError as the base class for application-level exceptions. Custom exception classes extend this hierarchy to represent domain-specific errors with tailored behavior, additional context, and structured error handling patterns.

Custom exceptions inherit from StandardError or its subclasses, gaining access to Ruby's exception handling mechanisms including rescue clauses, ensure blocks, and backtrace generation. The Exception class serves as the root but should not be used directly for application exceptions since it includes system-level errors like SystemExit and Interrupt.

Ruby's exception system supports custom initialization, message formatting, and additional instance variables to carry error context. Custom exceptions can override methods like #message, #to_s, and #inspect to provide enhanced error reporting.

class ValidationError < StandardError
  attr_reader :field, :value

  def initialize(field, value, message = nil)
    @field = field
    @value = value
    super(message || "Invalid value for #{field}: #{value}")
  end
end

raise ValidationError.new(:email, "invalid-email", "Email format is incorrect")

The primary use cases include domain modeling where different error types require distinct handling, API error responses with structured data, validation frameworks with field-specific errors, and complex applications needing hierarchical exception taxonomies.

Basic Usage

Custom exception classes extend StandardError and follow Ruby's class definition syntax. The constructor typically accepts a message string and additional parameters for error context. Ruby automatically provides backtrace generation, exception propagation, and integration with rescue clauses.

class DatabaseConnectionError < StandardError
  def initialize(host, port, message = "Connection failed")
    @host = host
    @port = port
    super("#{message}: #{host}:#{port}")
  end

  attr_reader :host, :port
end

begin
  raise DatabaseConnectionError.new("localhost", 5432)
rescue DatabaseConnectionError => e
  puts "Database error: #{e.message}"
  puts "Host: #{e.host}, Port: #{e.port}"
end

Exception hierarchies model error relationships through inheritance. Related exceptions share a common ancestor, enabling grouped rescue clauses that handle multiple error types with a single rescue block.

class APIError < StandardError; end
class AuthenticationError < APIError; end
class AuthorizationError < APIError; end
class RateLimitError < APIError; end

begin
  case error_type
  when :auth then raise AuthenticationError.new("Invalid credentials")
  when :authz then raise AuthorizationError.new("Access denied")
  when :rate then raise RateLimitError.new("Too many requests")
  end
rescue APIError => e
  # Handles all API-related errors
  log_api_error(e)
end

Custom exceptions can include additional data beyond the message string. Instance variables store error context, and accessor methods expose this data to rescue blocks for conditional error handling or detailed logging.

class ValidationError < StandardError
  attr_reader :errors

  def initialize(errors, message = nil)
    @errors = errors
    super(message || format_errors)
  end

  private

  def format_errors
    @errors.map { |field, msg| "#{field}: #{msg}" }.join(", ")
  end
end

errors = { name: "is required", email: "is invalid" }
raise ValidationError.new(errors)
# => ValidationError: name: is required, email: is invalid

The raise method accepts exception classes, exception instances, or strings. When passed a class, Ruby calls new with optional arguments. Exception instances are raised directly, while strings create RuntimeError instances.

# These are equivalent
raise ValidationError.new("Name is required")
raise ValidationError, "Name is required"

# String creates RuntimeError
raise "Something went wrong"  # RuntimeError: Something went wrong

Advanced Usage

Custom exception classes can implement complex initialization logic, override core methods, and provide specialized behavior for specific error handling patterns. The initialize method can accept structured data, perform validation, and establish default values for error context.

class HTTPError < StandardError
  attr_reader :status_code, :response_body, :headers, :request_method, :url

  def initialize(status_code, response_body: nil, headers: {}, request_method: nil, url: nil)
    @status_code = status_code
    @response_body = response_body
    @headers = headers || {}
    @request_method = request_method
    @url = url
    
    super(build_message)
  end

  def client_error?
    (400..499).include?(@status_code)
  end

  def server_error?
    (500..599).include?(@status_code)
  end

  def retriable?
    server_error? && @status_code != 501
  end

  private

  def build_message
    parts = ["HTTP #{@status_code}"]
    parts << "#{@request_method} #{@url}" if @request_method && @url
    parts << "Response: #{@response_body.slice(0, 100)}" if @response_body
    parts.join(" - ")
  end
end

Exception factories create specialized constructors for common error scenarios. Class methods provide domain-specific interfaces while maintaining consistent internal structure.

class FileProcessingError < StandardError
  attr_reader :filename, :line_number, :operation

  def initialize(filename, operation, line_number: nil, message: nil)
    @filename = filename
    @operation = operation  
    @line_number = line_number
    super(message || default_message)
  end

  def self.file_not_found(filename)
    new(filename, :read, message: "File not found: #{filename}")
  end

  def self.permission_denied(filename, operation)
    new(filename, operation, message: "Permission denied: #{operation} #{filename}")
  end

  def self.parse_error(filename, line_number, details)
    new(filename, :parse, line_number: line_number, message: "Parse error at line #{line_number}: #{details}")
  end

  private

  def default_message
    msg = "#{@operation.to_s.capitalize} operation failed on #{@filename}"
    msg += " at line #{@line_number}" if @line_number
    msg
  end
end

# Usage
raise FileProcessingError.file_not_found("config.yml")
raise FileProcessingError.parse_error("data.csv", 45, "Invalid date format")

Custom exceptions can include business logic methods that encapsulate error-specific behavior. These methods support conditional error handling, automatic retry logic, and error recovery strategies.

class RetriableError < StandardError
  attr_reader :retry_count, :max_retries, :delay, :original_error

  def initialize(message, max_retries: 3, delay: 1.0, original_error: nil)
    @retry_count = 0
    @max_retries = max_retries
    @delay = delay
    @original_error = original_error
    super(message)
  end

  def retriable?
    @retry_count < @max_retries
  end

  def increment_retry!
    @retry_count += 1
  end

  def next_delay
    @delay * (2 ** @retry_count)  # Exponential backoff
  end

  def retry_message
    "Retry #{@retry_count}/#{@max_retries} after #{next_delay}s delay"
  end
end

def process_with_retry
  retries = 0
  begin
    perform_operation
  rescue SomeError => e
    error = RetriableError.new("Operation failed", original_error: e)
    
    if error.retriable?
      error.increment_retry!
      puts error.retry_message
      sleep error.next_delay
      retry
    else
      raise error
    end
  end
end

Exception chaining preserves the original error context when wrapping exceptions. The cause attribute maintains a reference to the underlying exception, supporting root cause analysis and detailed error reporting.

class ServiceError < StandardError
  attr_reader :service_name, :cause

  def initialize(service_name, message, cause: nil)
    @service_name = service_name
    @cause = cause
    super(build_message(message))
  end

  def root_cause
    current = @cause
    current = current.cause while current&.respond_to?(:cause) && current.cause
    current
  end

  def full_message
    messages = [message]
    current = @cause
    while current
      messages << "Caused by: #{current.message}"
      current = current.respond_to?(:cause) ? current.cause : nil
    end
    messages.join("\n")
  end

  private

  def build_message(msg)
    "#{@service_name} service error: #{msg}"
  end
end

begin
  begin
    raise ArgumentError.new("Invalid parameter")
  rescue ArgumentError => e
    raise ServiceError.new("UserService", "Failed to create user", cause: e)
  end
rescue ServiceError => e
  puts e.full_message
  puts "Root cause: #{e.root_cause.class}: #{e.root_cause.message}"
end

Error Handling & Debugging

Custom exceptions provide structured approaches for error categorization, conditional rescue logic, and debugging support. Exception hierarchies enable granular rescue clauses that handle related errors differently based on their specific type and context.

Rescue clauses match exceptions using the case equality operator (===), which checks class inheritance. Multiple exception types can be rescued together, and variable assignment captures the exception instance for inspection and logging.

class NetworkError < StandardError; end
class TimeoutError < NetworkError; end  
class ConnectionError < NetworkError; end
class DNSError < NetworkError; end

def make_request(url)
  # Network operation
  raise TimeoutError.new("Request timed out after 30s")
end

begin
  make_request("https://api.example.com")
rescue TimeoutError => e
  # Specific handling for timeouts
  log_timeout(e)
  return { error: "timeout", retry_after: 60 }
rescue ConnectionError => e  
  # Handle connection issues
  log_connection_error(e)
  return { error: "connection", message: e.message }
rescue NetworkError => e
  # Catch-all for other network errors
  log_network_error(e)
  return { error: "network", message: e.message }
rescue StandardError => e
  # Handle unexpected errors
  log_unexpected_error(e)
  raise
end

Exception context data supports conditional error handling where rescue blocks examine error attributes to determine appropriate responses. This pattern enables sophisticated error recovery strategies based on error severity, type, or associated metadata.

class ValidationError < StandardError
  attr_reader :field, :value, :constraint, :severity

  def initialize(field, value, constraint, severity: :error)
    @field = field
    @value = value  
    @constraint = constraint
    @severity = severity
    super(build_message)
  end

  def recoverable?
    @severity == :warning
  end

  def critical?
    @severity == :critical
  end

  private

  def build_message
    "#{@severity.upcase}: #{@field} #{@constraint} (got: #{@value})"
  end
end

def validate_data(data)
  errors = []
  
  begin
    data.each do |field, value|
      validate_field(field, value)
    end
  rescue ValidationError => e
    if e.recoverable?
      # Log warning but continue processing
      warn e.message
      errors << e
    elsif e.critical?
      # Stop processing immediately
      raise e
    else
      # Collect error but continue
      errors << e
      retry_next_field
    end
  end
  
  raise ValidationError.new("multiple", errors, "validation failed") if errors.any?
end

Custom exceptions can implement debugging methods that provide additional context for error investigation. These methods expose internal state, format diagnostic information, and generate detailed error reports for development and production debugging.

class QueryError < StandardError
  attr_reader :sql, :params, :duration, :connection_info

  def initialize(sql, params: {}, duration: nil, connection_info: {})
    @sql = sql
    @params = params
    @duration = duration
    @connection_info = connection_info
    super(build_message)
  end

  def debug_info
    {
      query: formatted_sql,
      parameters: @params,
      execution_time: @duration,
      connection: @connection_info,
      timestamp: Time.now.iso8601,
      backtrace_excerpt: caller[0..5]
    }
  end

  def formatted_sql
    # Simple SQL formatting for readability
    @sql.gsub(/\s+/, ' ')
       .gsub(/\b(SELECT|FROM|WHERE|JOIN|ORDER|GROUP)\b/i) { "\n#{$1}" }
       .strip
  end

  def performance_impact
    return :unknown unless @duration
    
    case @duration
    when 0..0.1 then :low
    when 0.1..1.0 then :medium  
    when 1.0..5.0 then :high
    else :critical
    end
  end

  def to_h
    {
      message: message,
      class: self.class.name,
      debug_info: debug_info,
      performance_impact: performance_impact
    }
  end
end

begin
  raise QueryError.new(
    "SELECT * FROM users WHERE id = ?",
    params: { id: 12345 },
    duration: 2.3,
    connection_info: { host: "db.example.com", database: "production" }
  )
rescue QueryError => e
  # Structured logging with full context
  logger.error("Database query failed", e.to_h)
  
  # Performance monitoring
  if e.performance_impact == :critical
    alert_ops_team(e.debug_info)
  end
end

Exception aggregation patterns collect multiple related errors and present them as a single exception. This approach handles batch operations, validation of complex data structures, and scenarios where multiple failures should be reported together.

class AggregateError < StandardError
  attr_reader :errors

  def initialize(errors, message: nil)
    @errors = Array(errors)
    super(message || build_summary)
  end

  def error_count
    @errors.size
  end

  def errors_by_type
    @errors.group_by(&:class)
  end

  def critical_errors
    @errors.select { |e| e.respond_to?(:critical?) && e.critical? }
  end

  def detailed_report
    report = ["#{error_count} errors occurred:"]
    @errors.each_with_index do |error, idx|
      report << "  #{idx + 1}. #{error.class.name}: #{error.message}"
    end
    report.join("\n")
  end

  private

  def build_summary
    "Multiple errors occurred (#{error_count} total)"
  end
end

def process_batch(items)
  errors = []
  
  items.each_with_index do |item, index|
    begin
      process_item(item)
    rescue StandardError => e
      # Enhance error with context
      contextual_error = StandardError.new("Item #{index}: #{e.message}")
      contextual_error.define_singleton_method(:original_error) { e }
      errors << contextual_error
    end
  end
  
  raise AggregateError.new(errors) if errors.any?
end

Common Pitfalls

Ruby's exception inheritance hierarchy can lead to unexpected rescue behavior when custom exceptions inherit from the wrong base class. Exceptions inheriting directly from Exception bypass standard rescue clauses, which only catch StandardError and its subclasses by default.

# WRONG: Inheriting from Exception
class ConfigurationError < Exception
end

# This rescue won't catch ConfigurationError
begin
  raise ConfigurationError.new("Missing configuration")
rescue => e  # Only catches StandardError subclasses
  puts "Caught: #{e}"
end
# ConfigurationError propagates uncaught

# CORRECT: Inherit from StandardError
class ConfigurationError < StandardError
end

begin
  raise ConfigurationError.new("Missing configuration")  
rescue => e
  puts "Caught: #{e.class}: #{e.message}"
end
# => Caught: ConfigurationError: Missing configuration

Exception message construction in initialize methods can create performance issues when the message includes expensive operations like database queries, file I/O, or complex formatting. Ruby evaluates the message immediately when creating the exception, even if the exception is never raised.

# WRONG: Expensive operations in message construction
class UserNotFoundError < StandardError
  def initialize(user_id)
    # Database query executed even if exception isn't raised
    existing_users = User.pluck(:id).join(", ")
    super("User #{user_id} not found. Existing users: #{existing_users}")
  end
end

# CORRECT: Lazy message evaluation
class UserNotFoundError < StandardError
  attr_reader :user_id

  def initialize(user_id)
    @user_id = user_id
    super("User #{user_id} not found")
  end

  def detailed_message
    # Only compute when explicitly requested
    existing_users = User.pluck(:id).join(", ")
    "User #{@user_id} not found. Existing users: #{existing_users}"
  end
end

Custom exception classes that override to_s or message methods can break rescue clause behavior and logging systems that depend on these methods for error display. The message method should remain consistent and predictable across all exception instances.

# WRONG: Inconsistent message behavior
class ApiError < StandardError
  def initialize(status, response)
    @status = status
    @response = response
    super("API Error #{status}")
  end

  def message
    # Changes based on instance state
    if @status >= 500
      "Server Error #{@status}: #{@response}"
    else
      "Client Error #{@status}"
    end
  end
end

error = ApiError.new(500, "Internal Server Error")
puts error.message  # "Server Error 500: Internal Server Error"
# Later, if @response changes, message changes too

# CORRECT: Consistent message
class ApiError < StandardError
  attr_reader :status, :response

  def initialize(status, response)
    @status = status
    @response = response
    super(build_message)
  end

  def detailed_message
    "#{message} - Response: #{@response}"
  end

  private

  def build_message
    "API Error #{@status}"
  end
end

Exception hierarchies that are too deep or too flat create maintenance problems. Deep hierarchies make rescue clauses complex and error handling unpredictable, while flat hierarchies lose the benefits of grouped exception handling.

# WRONG: Too deep hierarchy
class Error < StandardError; end
class NetworkError < Error; end  
class HTTPError < NetworkError; end
class HTTPClientError < HTTPError; end
class HTTPNotFoundError < HTTPClientError; end
class HTTPResourceNotFoundError < HTTPNotFoundError; end

# Rescue becomes unwieldy
begin
  operation
rescue HTTPResourceNotFoundError => e
  # Very specific handling
rescue HTTPNotFoundError => e  
  # Less specific
rescue HTTPClientError => e
  # Even less specific
rescue HTTPError => e
  # General HTTP error
rescue NetworkError => e
  # Network level
rescue Error => e
  # Application level
end

# CORRECT: Balanced hierarchy
class NetworkError < StandardError; end
class HTTPError < NetworkError
  attr_reader :status_code
  
  def initialize(status_code, message)
    @status_code = status_code
    super(message)
  end

  def client_error?
    (400..499).include?(@status_code)
  end

  def server_error?
    (500..599).include?(@status_code)
  end
end

class HTTPNotFoundError < HTTPError
  def initialize(resource)
    super(404, "Resource not found: #{resource}")
  end
end

# Clean, predictable rescue
begin
  operation
rescue HTTPNotFoundError => e
  handle_not_found(e)
rescue HTTPError => e
  if e.client_error?
    handle_client_error(e)
  else
    handle_server_error(e)
  end
rescue NetworkError => e
  handle_network_error(e)
end

Custom exceptions that don't call super in their initialize method lose the standard exception message and backtrace functionality. This breaks compatibility with Ruby's exception handling infrastructure and debugging tools.

# WRONG: Not calling super
class ValidationError < StandardError
  attr_reader :field, :errors

  def initialize(field, errors)
    @field = field
    @errors = errors
    # Missing super call - no message set
  end
end

error = ValidationError.new(:email, ["is required"])
puts error.message  # => ""
puts error.to_s     # => ""

# CORRECT: Always call super
class ValidationError < StandardError  
  attr_reader :field, :errors

  def initialize(field, errors)
    @field = field
    @errors = errors
    super(build_message)
  end

  private

  def build_message
    "#{@field}: #{@errors.join(', ')}"
  end
end

error = ValidationError.new(:email, ["is required"])
puts error.message  # => "email: is required"
puts error.to_s     # => "email: is required"

Reference

Base Exception Classes

Class Purpose When to Use
Exception Root of exception hierarchy Never inherit directly
StandardError Base for application exceptions Default base class
RuntimeError Generic runtime errors Simple custom errors
ArgumentError Invalid argument errors Parameter validation
TypeError Type-related errors Type checking failures
NameError Name resolution errors Missing constants/methods

Custom Exception Definition Patterns

# Basic custom exception
class CustomError < StandardError; end

# Exception with additional attributes
class DetailedError < StandardError
  attr_reader :code, :context
  
  def initialize(message, code: nil, context: {})
    @code = code
    @context = context
    super(message)
  end
end

# Exception with factory methods
class APIError < StandardError
  def self.timeout(service)
    new("#{service} request timed out")
  end
  
  def self.unauthorized(resource)
    new("Access denied to #{resource}")
  end
end

Exception Handling Methods

Method Returns Description
#message String Exception message text
#to_s String String representation
#inspect String Detailed object inspection
#backtrace Array<String> Stack trace lines
#backtrace_locations Array<Thread::Backtrace::Location> Location objects
#cause Exception or nil Previous exception in chain
#full_message String Complete formatted message

Rescue Clause Patterns

# Single exception type
rescue CustomError => e

# Multiple exception types  
rescue CustomError, AnotherError => e

# Exception hierarchy
rescue StandardError => e

# Bare rescue (catches StandardError)
rescue => e

# Re-raise with modification
rescue CustomError => e
  raise e.class, "Modified: #{e.message}", e.backtrace

Exception Raising Patterns

Pattern Result
raise CustomError Creates new instance with no message
raise CustomError, "message" Creates new instance with message
raise CustomError.new("message") Raises specific instance
raise "string message" Creates RuntimeError with message
raise Re-raises current exception

Common Exception Attributes

class ContextualError < StandardError
  attr_reader :timestamp, :user_id, :request_id, :severity
  
  def initialize(message, **context)
    @timestamp = context[:timestamp] || Time.now
    @user_id = context[:user_id]
    @request_id = context[:request_id]
    @severity = context[:severity] || :error
    super(message)
  end

  def to_h
    {
      message: message,
      timestamp: @timestamp,
      user_id: @user_id,
      request_id: @request_id,
      severity: @severity,
      class: self.class.name
    }
  end
end

Exception Hierarchy Design

# Domain-specific base
class ServiceError < StandardError; end

# Operational categories
class ServiceError::Timeout < ServiceError; end
class ServiceError::Connection < ServiceError; end  
class ServiceError::Authentication < ServiceError; end
class ServiceError::Authorization < ServiceError; end

# Specific implementations
class ServiceError::Timeout::Read < ServiceError::Timeout; end
class ServiceError::Timeout::Write < ServiceError::Timeout; end

Error Handling Decision Table

Error Type Rescue Level Action Log Level
Validation Method Return error response INFO
Authentication Controller Redirect to login WARN
Authorization Controller Return 403 WARN
Network timeout Service Retry with backoff ERROR
Database connection Application Failover to readonly ERROR
System resource Application Graceful degradation FATAL