BUCKYVERSE

Error Handling Patterns: Ruby to Elixir Migration

This document provides guidance on translating Ruby's exception-based error handling to Elixir's conventional {:ok, result} | {:error, reason} pattern. It includes concrete examples and best practices to follow during the migration process.

Fundamental Differences

Ruby's Approach

Ruby primarily uses exceptions for error handling, with a try/catch (begin/rescue) mechanism:

def process_file(path)
  begin
    content = File.read(path)
    # Process content
    return content
  rescue Errno::ENOENT
    puts "File not found: #{path}"
    return nil
  rescue => e
    puts "Error processing file: #{e.message}"
    return nil
  end
end

Elixir's Approach

Elixir uses return values for expected error conditions and exceptions only for exceptional cases:

def process_file(path) do
  case File.read(path) do
    {:ok, content} ->
      # Process content
      {:ok, content}
    {:error, :enoent} ->
      Logger.warn("File not found: #{path}")
      {:error, :file_not_found}
    {:error, reason} ->
      Logger.error("Error processing file: #{inspect(reason)}")
      {:error, :processing_failed}
  end
end

Core Pattern Translations

1. Basic Error Handling

Ruby:

def validate_metadata(metadata)
  if metadata.nil?
    raise ArgumentError, "Metadata cannot be nil"
  end
  
  if metadata[:title].nil? || metadata[:title].empty?
    raise ValidationError, "Title is required"
  end
  
  # Valid metadata
  metadata
end

Elixir:

def validate_metadata(metadata) do
  cond do
    is_nil(metadata) ->
      {:error, :metadata_required}
    is_nil(metadata[:title]) || metadata[:title] == "" ->
      {:error, :title_required}
    true ->
      {:ok, metadata}
  end
end

2. Error Propagation

Ruby:

def process_book(book_path)
  begin
    metadata = load_metadata(book_path)
    content = load_content(book_path)
    process_content(content, metadata)
  rescue MetadataError => e
    puts "Metadata error: #{e.message}"
    nil
  rescue ContentError => e
    puts "Content error: #{e.message}"
    nil
  rescue => e
    puts "Unknown error: #{e.message}"
    nil
  end
end

def load_metadata(path)
  # Can raise MetadataError
end

def load_content(path)
  # Can raise ContentError
end

Elixir:

def process_book(book_path) do
  with {:ok, metadata} <- load_metadata(book_path),
       {:ok, content} <- load_content(book_path),
       {:ok, result} <- process_content(content, metadata) do
    {:ok, result}
  else
    {:error, :metadata_invalid} ->
      Logger.warn("Metadata error for book: #{book_path}")
      {:error, :metadata_invalid}
    {:error, :content_invalid} ->
      Logger.warn("Content error for book: #{book_path}")
      {:error, :content_invalid}
    {:error, reason} ->
      Logger.error("Unknown error: #{inspect(reason)}")
      {:error, :processing_failed}
  end
end

def load_metadata(path) do
  # Returns {:ok, metadata} or {:error, reason}
end

def load_content(path) do
  # Returns {:ok, content} or {:error, reason}
end

3. Custom Error Types

Ruby:

class ValidationError < StandardError; end
class ConversionError < StandardError; end
class ResourceError < StandardError; end

def convert_format(content, format)
  case format
  when :pdf
    begin
      convert_to_pdf(content)
    rescue => e
      raise ConversionError, "PDF conversion failed: #{e.message}"
    end
  when :epub
    begin
      convert_to_epub(content)
    rescue => e
      raise ConversionError, "EPUB conversion failed: #{e.message}"
    end
  else
    raise ValidationError, "Unsupported format: #{format}"
  end
end

Elixir:

def convert_format(content, format) do
  case format do
    :pdf ->
      case convert_to_pdf(content) do
        {:ok, result} -> {:ok, result}
        {:error, reason} -> {:error, {:pdf_conversion, reason}}
      end
    :epub ->
      case convert_to_epub(content) do
        {:ok, result} -> {:ok, result}
        {:error, reason} -> {:error, {:epub_conversion, reason}}
      end
    _ ->
      {:error, {:unsupported_format, format}}
  end
end

Advanced Pattern Translations

1. Context-Rich Errors

Ruby:

def build_ebook(manuscript, options = {})
  begin
    validate_manuscript!(manuscript)
    validate_options!(options)
    
    format = options[:format] || :pdf
    result = convert_manuscript(manuscript, format)
    
    if options[:validate]
      validate_result!(result, format)
    end
    
    result
  rescue ValidationError => e
    error_context = {
      manuscript: manuscript.path,
      format: format,
      stage: :validation
    }
    log_error(e, error_context)
    raise
  rescue ConversionError => e
    error_context = {
      manuscript: manuscript.path,
      format: format,
      stage: :conversion
    }
    log_error(e, error_context)
    raise
  end
end

Elixir:

def build_ebook(manuscript, options \\ %{}) do
  with {:ok, _} <- validate_manuscript(manuscript),
       {:ok, validated_options} <- validate_options(options),
       format = Map.get(validated_options, :format, :pdf),
       {:ok, result} <- convert_manuscript(manuscript, format),
       {:ok, validated_result} <- maybe_validate_result(result, format, validated_options) do
    {:ok, validated_result}
  else
    {:error, {:validation, reason}} ->
      error_context = %{
        manuscript: manuscript.path,
        stage: :validation
      }
      Logger.error("Validation error: #{inspect(reason)}", error_context)
      {:error, {:validation_failed, reason}}
      
    {:error, {:conversion, reason}} ->
      error_context = %{
        manuscript: manuscript.path,
        format: Map.get(options, :format, :pdf),
        stage: :conversion
      }
      Logger.error("Conversion error: #{inspect(reason)}", error_context)
      {:error, {:conversion_failed, reason}}
      
    {:error, reason} ->
      Logger.error("Unknown error: #{inspect(reason)}")
      {:error, {:build_failed, reason}}
  end
end

defp maybe_validate_result(result, format, options) do
  if Map.get(options, :validate, false) do
    validate_result(result, format)
  else
    {:ok, result}
  end
end

2. Error Recovery

Ruby:

def process_chapters(book)
  results = []
  errors = []
  
  book.chapters.each do |chapter|
    begin
      result = process_chapter(chapter)
      results << result
    rescue => e
      errors << {chapter: chapter.title, error: e.message}
      # Continue processing other chapters
    end
  end
  
  if errors.any?
    puts "Completed with #{errors.length} errors:"
    errors.each do |error|
      puts "  - #{error[:chapter]}: #{error[:error]}"
    end
  end
  
  {results: results, errors: errors}
end

Elixir:

def process_chapters(book) do
  {results, errors} = 
    Enum.reduce(book.chapters, {[], []}, fn chapter, {results, errors} ->
      case process_chapter(chapter) do
        {:ok, result} ->
          {[result | results], errors}
        {:error, reason} ->
          error_entry = %{chapter: chapter.title, error: reason}
          {results, [error_entry | errors]}
      end
    end)
  
  results = Enum.reverse(results)
  errors = Enum.reverse(errors)
  
  if length(errors) > 0 do
    Logger.warn("Completed with #{length(errors)} errors:")
    Enum.each(errors, fn error ->
      Logger.warn("  - #{error.chapter}: #{inspect(error.error)}")
    end)
  end
  
  {:ok, %{results: results, errors: errors}}
end

3. Using with for Multiple Operations

Ruby:

def generate_formats(manuscript)
  begin
    pdf_result = generate_pdf(manuscript)
    epub_result = generate_epub(manuscript)
    mobi_result = generate_mobi(manuscript)
    
    {
      pdf: pdf_result,
      epub: epub_result,
      mobi: mobi_result
    }
  rescue PDFGenerationError => e
    puts "PDF generation failed: #{e.message}"
    nil
  rescue EPUBGenerationError => e
    puts "EPUB generation failed: #{e.message}"
    nil
  rescue MOBIGenerationError => e
    puts "MOBI generation failed: #{e.message}"
    nil
  end
end

Elixir:

def generate_formats(manuscript) do
  with {:ok, pdf_result} <- generate_pdf(manuscript),
       {:ok, epub_result} <- generate_epub(manuscript),
       {:ok, mobi_result} <- generate_mobi(manuscript) do
    {:ok, %{
      pdf: pdf_result,
      epub: epub_result,
      mobi: mobi_result
    }}
  else
    {:error, {:pdf, reason}} ->
      Logger.error("PDF generation failed: #{inspect(reason)}")
      {:error, {:pdf_generation_failed, reason}}
      
    {:error, {:epub, reason}} ->
      Logger.error("EPUB generation failed: #{inspect(reason)}")
      {:error, {:epub_generation_failed, reason}}
      
    {:error, {:mobi, reason}} ->
      Logger.error("MOBI generation failed: #{inspect(reason)}")
      {:error, {:mobi_generation_failed, reason}}
  end
end

Best Practices for Elixir Error Handling

When to Use Exceptions vs. Return Values

Use Return Values For:

def find_book(id) do
  case BookRepo.get(id) do
    nil -> {:error, :not_found}
    book -> {:ok, book}
  end
end

Use Exceptions For:

def parse_config!(path) do
  case File.read(path) do
    {:ok, content} ->
      case Jason.decode(content) do
        {:ok, config} -> config
        {:error, reason} -> 
          raise "Invalid configuration: #{inspect(reason)}"
      end
    {:error, reason} ->
      raise "Could not read config: #{inspect(reason)}"
  end
end

Error Message Formatting

Ruby:

raise ValidationError, "Invalid title: '#{title}' (must be 1-100 characters)"

Elixir:

{:error, {:invalid_title, title, "must be 1-100 characters"}}

Creating Custom Error Types

Ruby:

module Errors
  class ValidationError < StandardError; end
  class ConversionError < StandardError; end
  class ResourceError < StandardError; end
end

Elixir:

# Using atoms and tuples
{:error, :validation_error}
{:error, {:validation_error, :title, "Title is required"}}

# For more complex cases, using structs
defmodule Codexis.Error do
  defexception [:type, :reason, :context]
  
  @type t :: %__MODULE__{
    type: atom(),
    reason: any(),
    context: map()
  }
  
  def message(%__MODULE__{type: type, reason: reason, context: context}) do
    "Error [#{type}]: #{inspect(reason)}, Context: #{inspect(context)}"
  end
end

# Usage
{:error, %Codexis.Error{
  type: :validation,
  reason: :title_required,
  context: %{manuscript_id: 123}
}}

Error Recovery Patterns

Fallback Values:

def get_title(book) do
  case Map.fetch(book, :title) do
    {:ok, title} -> title
    :error -> "Untitled"
  end
end

Default Values with ||:

def get_format_option(options) do
  case options[:format] do
    nil -> {:ok, :pdf}  # Default value
    format when format in [:pdf, :epub, :mobi] -> {:ok, format}
    invalid -> {:error, {:invalid_format, invalid}}
  end
end

Maybe Pattern:

def maybe_validate(document, options) do
  if Map.get(options, :validate, true) do
    validate_document(document)
  else
    {:ok, document}
  end
end

Integration with Logging

Ruby:

def process_manuscript(manuscript)
  begin
    logger.info("Processing manuscript: #{manuscript.title}")
    # Processing logic
    logger.info("Successfully processed manuscript")
    result
  rescue => e
    logger.error("Failed to process manuscript: #{e.message}")
    logger.debug(e.backtrace.join("\n"))
    raise
  end
end

Elixir:

def process_manuscript(manuscript) do
  Logger.info("Processing manuscript: #{manuscript.title}")
  
  case do_process_manuscript(manuscript) do
    {:ok, result} ->
      Logger.info("Successfully processed manuscript")
      {:ok, result}
    {:error, reason} ->
      Logger.error("Failed to process manuscript: #{inspect(reason)}")
      {:error, reason}
  end
end

Parallel Error Handling

Ruby:

def process_in_parallel(items)
  results = Parallel.map(items, in_threads: 4) do |item|
    begin
      process_item(item)
    rescue => e
      {error: e.message, item: item}
    end
  end
  
  successes = results.reject { |r| r.is_a?(Hash) && r.key?(:error) }
  errors = results.select { |r| r.is_a?(Hash) && r.key?(:error) }
  
  {successes: successes, errors: errors}
end

Elixir:

def process_in_parallel(items) do
  results = 
    Task.async_stream(items, fn item ->
      case process_item(item) do
        {:ok, result} -> {:success, result}
        {:error, reason} -> {:error, {reason, item}}
      end
    end, timeout: 30_000)
    |> Enum.to_list()
    |> Enum.map(fn {:ok, result} -> result end)
  
  successes = 
    results
    |> Enum.filter(fn
      {:success, _} -> true
      _ -> false
    end)
    |> Enum.map(fn {:success, result} -> result end)
    
  errors = 
    results
    |> Enum.filter(fn
      {:error, _} -> true
      _ -> false
    end)
    |> Enum.map(fn {:error, details} -> details end)
  
  {:ok, %{successes: successes, errors: errors}}
end

Conclusion

Migrating from Ruby's exception-based error handling to Elixir's {:ok, result} | {:error, reason} pattern requires a shift in thinking, but results in more explicit and predictable error handling. The key differences to remember:

  1. In Ruby, exceptions control the flow; in Elixir, return values control the flow
  2. Ruby uses begin/rescue blocks; Elixir uses pattern matching with case and with
  3. Ruby raises exceptions for many conditions; Elixir reserves exceptions for truly exceptional cases
  4. Ruby often returns nil for errors; Elixir returns structured {:error, reason} tuples

By following these patterns, your Elixir code will be more idiomatic and take better advantage of Elixir's strengths in error handling and recovery.