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 |