【保存版】Rubycueで実現する堅実なエラーハンドリング 7つの実践テクニック

Rubyのエラーハンドリングの基礎知識

rescueが必要な理由と基本的な使い方

プログラムの実行中に予期せぬエラーが発生することは避けられません。ファイルの読み込みに失敗したり、APIからの応答がタイムアウトしたり、データベースへの接続が切断されたりする可能性は常にあります。これらのエラーを適切に処理せずにプログラムが突然停止してしまうと、ユーザー体験が著しく損なわれ、データの整合性が失われる可能性もあります。

そこで重要になるのがrescueを使用したエラーハンドリングです。以下のような利点があります:

  1. プログラムの堅牢性向上
  • エラーが発生しても適切に処理を継続
  • ユーザーへの適切なフィードバック提供
  1. デバッグの効率化
  • エラーの発生箇所と原因の特定が容易
  • ログによるエラー追跡が可能
  1. ユーザー体験の向上
  • エラー時の代替処理の提供
  • わかりやすいエラーメッセージの表示

基本的な使用例を見てみましょう:

# 基本的なrescueの使用例
begin
  # エラーが発生する可能性のある処理
  result = potentially_dangerous_operation()
rescue StandardError => e
  # エラーが発生した場合の処理
  logger.error("エラーが発生しました: #{e.message}")
  # 代替の処理や適切なフィードバック
  notify_user("処理に失敗しました")
end

# メソッド定義内でのrescue
def safe_operation
  # 危険な処理
  result = risky_operation()
rescue StandardError => e
  # エラー処理
  handle_error(e)
else
  # エラーが発生しなかった場合の処理
  success_operation()
ensure
  # 必ず実行される処理
  cleanup_resources()
end

Rubyに実装されている主要例外クラス

Rubyには様々な例外クラスが実装されており、階層構造を持っています。主要な例外クラスとその用途を理解することで、より適切なエラーハンドリングが可能になります。

例外クラスの階層構造

Exception
  ├── NoMemoryError        # メモリ不足
  ├── ScriptError         
  │   ├── LoadError       # ライブラリ読み込みエラー
  │   ├── NotImplementedError
  │   └── SyntaxError     # 文法エラー
  └── StandardError       # 通常のプログラムエラー
      ├── ArgumentError   # 引数関連のエラー
      ├── IndexError      # 配列のインデックスエラー
      ├── NameError       # 未定義の変数/メソッド
      ├── NoMethodError   # メソッド未定義
      ├── RangeError     # 範囲外の値
      ├── RuntimeError    # デフォルトのエラー
      └── TypeError      # 型の不一致

主要な例外クラスの使用例

# ArgumentError
def greet(name)
  raise ArgumentError, "名前を入力してください" if name.nil?
  puts "Hello, #{name}!"
end

# NoMethodError
begin
  undefined_object.undefined_method
rescue NoMethodError => e
  puts "未定義のメソッドが呼び出されました: #{e.message}"
end

# TypeError
begin
  "string" + 1  # 文字列と数値の加算
rescue TypeError => e
  puts "型の不一致が発生しました: #{e.message}"
end

# カスタム例外の定義
class CustomError < StandardError
  def initialize(msg = "カスタムエラーが発生しました")
    super
  end
end

これらの例外クラスを適切に使い分けることで、より具体的なエラー処理が可能になります。特に重要なのは:

  1. StandardErrorとそのサブクラスが最も一般的に使用される
  2. rescue節で例外クラスを指定しない場合はStandardErrorが捕捉される
  3. カスタム例外は通常StandardErrorを継承して作成する

エラーハンドリングの基本を押さえることで、より信頼性の高いプログラムを作成することができます。

Rubyのエラーハンドリングの基礎知識

rescueが必要な理由と基本的な使い方

プログラムの実行中に予期せぬエラーが発生することは避けられません。ファイルの読み込みに失敗したり、APIからの応答がタイムアウトしたり、データベースへの接続が切断されたりする可能性は常にあります。これらのエラーを適切に処理せずにプログラムが突然停止してしまうと、ユーザー体験が著しく損なわれ、データの整合性が失われる可能性もあります。

そこで重要になるのがrescueを使用したエラーハンドリングです。以下のような利点があります:

  1. プログラムの堅牢性向上
  • エラーが発生しても適切に処理を継続
  • ユーザーへの適切なフィードバック提供
  1. デバッグの効率化
  • エラーの発生箇所と原因の特定が容易
  • ログによるエラー追跡が可能
  1. ユーザー体験の向上
  • エラー時の代替処理の提供
  • わかりやすいエラーメッセージの表示

基本的な使用例を見てみましょう:

# 基本的なrescueの使用例
begin
  # エラーが発生する可能性のある処理
  result = potentially_dangerous_operation()
rescue StandardError => e
  # エラーが発生した場合の処理
  logger.error("エラーが発生しました: #{e.message}")
  # 代替の処理や適切なフィードバック
  notify_user("処理に失敗しました")
end

# メソッド定義内でのrescue
def safe_operation
  # 危険な処理
  result = risky_operation()
rescue StandardError => e
  # エラー処理
  handle_error(e)
else
  # エラーが発生しなかった場合の処理
  success_operation()
ensure
  # 必ず実行される処理
  cleanup_resources()
end

Rubyに実装されている主要例外クラス

Rubyには様々な例外クラスが実装されており、階層構造を持っています。主要な例外クラスとその用途を理解することで、より適切なエラーハンドリングが可能になります。

例外クラスの階層構造

Exception
  ├── NoMemoryError        # メモリ不足
  ├── ScriptError         
  │   ├── LoadError       # ライブラリ読み込みエラー
  │   ├── NotImplementedError
  │   └── SyntaxError     # 文法エラー
  └── StandardError       # 通常のプログラムエラー
      ├── ArgumentError   # 引数関連のエラー
      ├── IndexError      # 配列のインデックスエラー
      ├── NameError       # 未定義の変数/メソッド
      ├── NoMethodError   # メソッド未定義
      ├── RangeError     # 範囲外の値
      ├── RuntimeError    # デフォルトのエラー
      └── TypeError      # 型の不一致

主要な例外クラスの使用例

# ArgumentError
def greet(name)
  raise ArgumentError, "名前を入力してください" if name.nil?
  puts "Hello, #{name}!"
end

# NoMethodError
begin
  undefined_object.undefined_method
rescue NoMethodError => e
  puts "未定義のメソッドが呼び出されました: #{e.message}"
end

# TypeError
begin
  "string" + 1  # 文字列と数値の加算
rescue TypeError => e
  puts "型の不一致が発生しました: #{e.message}"
end

# カスタム例外の定義
class CustomError < StandardError
  def initialize(msg = "カスタムエラーが発生しました")
    super
  end
end

これらの例外クラスを適切に使い分けることで、より具体的なエラー処理が可能になります。特に重要なのは:

  1. StandardErrorとそのサブクラスが最も一般的に使用される
  2. rescue節で例外クラスを指定しない場合はStandardErrorが捕捉される
  3. カスタム例外は通常StandardErrorを継承して作成する

エラーハンドリングの基本を押さえることで、より信頼性の高いプログラムを作成することができます。

rescueblock処理の効果的な実装パターン

基本的なrescue構文の書き方

rescueブロックの基本的な構文には、メソッド内での直接的な使用とbegin-rescue-endブロックの2つのパターンがあります。それぞれの特徴と使い分けを理解することが重要です。

# パターン1: メソッド内での直接的な使用
def process_data(data)
  # 危険な処理
  process_dangerous_operation(data)
rescue StandardError => e
  # エラー処理
  log_error(e)
  raise # エラーを再送出する場合
end

# パターン2: begin-rescue-endブロック
begin
  # 危険な処理をブロックで囲む
  connection = DB.connect
  data = connection.query("SELECT * FROM users")
rescue StandardError => e
  # エラー処理
  log_error(e)
ensure
  # リソースのクリーンアップ
  connection&.close
end

どちらのパターンを選択するかは、以下の基準で判断します:

  1. メソッド内での直接的な使用
  • メソッド全体がエラーハンドリングの対象の場合
  • コードがシンプルで見通しが良い場合
  • 単一の処理に対するエラー処理の場合
  1. begin-rescue-endブロック
  • 特定のブロックのみをエラーハンドリングしたい場合
  • 複数の処理を一つのエラーハンドリングでカバーする場合
  • ensure句でリソースの解放が必要な場合

複数の例外を個別に扱う方法

実践的なアプリケーションでは、複数の種類の例外を個別に処理する必要があることがよくあります。Rubyでは、複数の例外を効率的に処理する方法が用意されています。

def complex_operation
  begin
    # データベース操作
    result = db_operation()

    # API呼び出し
    api_result = api_call()

    # ファイル操作
    file_result = file_operation()

  rescue ActiveRecord::RecordNotFound => e
    # レコードが見つからない場合の処理
    log_error("データが見つかりません: #{e.message}")
    raise CustomNotFoundError, "要求されたリソースが見つかりません"

  rescue Net::HTTPError => e
    # API通信エラーの処理
    log_error("API通信エラー: #{e.message}")
    notify_admin("APIエラーが発生しました")
    retry if should_retry?

  rescue Errno::ENOENT => e
    # ファイル関連エラーの処理
    log_error("ファイルエラー: #{e.message}")
    create_empty_file
    retry

  rescue StandardError => e
    # その他の一般的なエラーの処理
    log_error("予期せぬエラー: #{e.message}")
    notify_admin(e)
  end
end

このパターンの利点:

  1. 例外の種類ごとに適切な処理が可能
  2. エラーメッセージをより具体的に設定可能
  3. 例外の種類に応じて異なるリカバリー戦略を実装可能

else句とensure句の適切な使用方法

else句とensure句を使用することで、より細かい制御が可能になります。これらの句の適切な使用方法を見ていきましょう。

def process_with_cleanup
  file = File.open("important.txt", "w")
  begin
    # 危険な処理
    process_data(file)
  rescue IOError => e
    # ファイル操作エラーの処理
    log_error("ファイル操作エラー: #{e.message}")
    false  # 処理失敗を示す戻り値
  else
    # エラーが発生しなかった場合の処理
    log_success("処理が成功しました")
    true   # 処理成功を示す戻り値
  ensure
    # 必ず実行される処理(リソースのクリーンアップ)
    file.close if file
  end
end

# より実践的な例:トランザクション処理
def transaction_with_retry
  retry_count = 0
  begin
    ActiveRecord::Base.transaction do
      # データベース処理
      user.save!
      order.update!(status: 'completed')
    end
  rescue ActiveRecord::RecordInvalid => e
    # バリデーションエラーの処理
    log_validation_error(e)
    raise
  rescue ActiveRecord::StaleObjectError => e
    # 楽観的ロックエラーの処理
    retry_count += 1
    if retry_count < 3
      sleep(0.1 * retry_count)
      retry
    else
      raise
    end
  else
    # トランザクション成功時の処理
    notify_success
  ensure
    # クリーンアップ処理
    cleanup_temporary_data
  end
end

else句とensure句の使用指針:

  1. else句の使用場合:
  • エラーが発生しなかった場合の特別な処理が必要な時
  • 正常系と異常系の処理を明確に分けたい時
  • 処理の成功を明示的に記録したい時
  1. ensure句の使用場合:
  • ファイルやデータベース接続のクローズ
  • 一時ファイルの削除
  • ロックの解放
  • ログ出力の完了処理

これらのパターンを適切に組み合わせることで、より堅牢なエラーハンドリングを実現できます。

7つの実践的なエラーハンドリングテクニック

テクニック1:例外クラスの継承を活用した処理

例外クラスを継承して独自の例外クラスを作成することで、より細かいエラー制御が可能になります。

# 基底となる独自例外クラス
class ApplicationError < StandardError
  attr_reader :code, :details

  def initialize(message = "An error occurred", code = nil, details = {})
    @code = code
    @details = details
    super(message)
  end
end

# 具体的な例外クラス
class ValidationError < ApplicationError
  def initialize(message = "Validation failed", details = {})
    super(message, "VALIDATION_ERROR", details)
  end
end

class APIError < ApplicationError
  def initialize(message = "API request failed", details = {})
    super(message, "API_ERROR", details)
  end
end

# 実践的な使用例
def process_user_data(user_params)
  begin
    validate_user(user_params)
    api_result = call_external_api(user_params)
    save_user_data(api_result)
  rescue ValidationError => e
    log_error("バリデーションエラー", e.code, e.details)
    raise
  rescue APIError => e
    log_error("API呼び出しエラー", e.code, e.details)
    notify_admin(e)
    false
  end
end

テクニック2:retry機能を使用した自動リカバリー

一時的な障害に対して、retryを使用して自動的にリカバリーを試みることができます。

class APIClient
  MAX_RETRIES = 3
  RETRY_WAIT = 1.0

  def self.fetch_data(endpoint)
    retries = 0
    begin
      response = HTTP.timeout(5).get(endpoint)
      handle_response(response)
    rescue HTTP::TimeoutError, HTTP::ConnectionError => e
      retries += 1
      if retries <= MAX_RETRIES
        wait_time = RETRY_WAIT * (2 ** (retries - 1)) # 指数バックオフ
        logger.warn("API呼び出しに失敗しました。#{wait_time}秒後にリトライします。(#{retries}/#{MAX_RETRIES})")
        sleep(wait_time)
        retry
      else
        logger.error("APIリトライ上限に達しました: #{e.message}")
        raise APIError.new("API呼び出しが#{MAX_RETRIES}回失敗しました", details: { endpoint: endpoint })
      end
    end
  end

  private

  def self.handle_response(response)
    case response.code
    when 200..299
      JSON.parse(response.body.to_s)
    when 404
      raise NotFoundError.new("リソースが見つかりません")
    when 500..599
      raise ServerError.new("サーバーエラーが発生しました")
    else
      raise APIError.new("予期せぬレスポンスコード: #{response.code}")
    end
  end
end

テクニック3:begin-rescueのスコープ最適化

エラーハンドリングのスコープを最適化することで、より正確なエラー処理が可能になります。

class DocumentProcessor
  def process_document(document_path)
    # ファイルの存在チェックは個別に行う
    unless File.exist?(document_path)
      raise ArgumentError, "ファイルが存在しません: #{document_path}"
    end

    begin
      # ファイル読み込み処理のみをrescueで囲む
      content = File.read(document_path)
    rescue SystemCallError => e
      logger.error("ファイル読み込みエラー: #{e.message}")
      raise IOError, "ファイルを読み込めません: #{e.message}"
    end

    begin
      # パース処理は別のrescueブロックで処理
      parsed_data = JSON.parse(content)
    rescue JSON::ParserError => e
      logger.error("JSONパースエラー: #{e.message}")
      raise ValidationError, "不正なJSONフォーマット"
    end

    # 処理結果を返す
    processed_result = process_data(parsed_data)
    { status: :success, data: processed_result }
  end
end

テクニック4:例外情報の効果的なログ記録

デバッグや障害分析に役立つ情報を適切にログに記録することが重要です。

module ErrorLogger
  class << self
    def log_error(error, context = {})
      error_info = {
        error_class: error.class.name,
        message: error.message,
        backtrace: error.backtrace&.first(5),
        timestamp: Time.current,
        environment: Rails.env,
        context: context
      }

      # エラー情報の構造化
      case error
      when ActiveRecord::RecordInvalid
        log_validation_error(error, error_info)
      when ActionController::ParameterMissing
        log_parameter_error(error, error_info)
      else
        log_general_error(error, error_info)
      end
    end

    private

    def log_validation_error(error, info)
      info[:validation_errors] = error.record.errors.messages
      Rails.logger.warn(info.to_json)
    end

    def log_parameter_error(error, info)
      info[:parameters] = error.param
      Rails.logger.warn(info.to_json)
    end

    def log_general_error(error, info)
      Rails.logger.error(info.to_json)
    end
  end
end

テクニック5:カスタム例外クラスの設計と活用

ドメイン固有の例外を定義することで、より明確なエラー処理が可能になります。

module PaymentSystem
  # 支払い関連の基底例外クラス
  class PaymentError < StandardError
    attr_reader :payment_id, :error_code

    def initialize(message, payment_id: nil, error_code: nil)
      @payment_id = payment_id
      @error_code = error_code
      super(message)
    end
  end

  # 具体的な例外クラス
  class InsufficientFundsError < PaymentError
    def initialize(payment_id:, amount:, balance:)
      super(
        "残高不足です(必要額: #{amount}円、残高: #{balance}円)",
        payment_id: payment_id,
        error_code: 'INSUFFICIENT_FUNDS'
      )
    end
  end

  class PaymentProcessingError < PaymentError
    def initialize(payment_id:, provider_message:)
      super(
        "決済処理に失敗しました: #{provider_message}",
        payment_id: payment_id,
        error_code: 'PROCESSING_ERROR'
      )
    end
  end

  # 実装例
  class PaymentProcessor
    def process_payment(payment)
      validate_payment(payment)
      process_with_provider(payment)
    rescue PaymentError => e
      handle_payment_error(e)
    end

    private

    def validate_payment(payment)
      if payment.amount > payment.user.balance
        raise InsufficientFundsError.new(
          payment_id: payment.id,
          amount: payment.amount,
          balance: payment.user.balance
        )
      end
    end
  end
end

テクニック6:rescue修飾子を使ったシンプルな実装

単純なエラー処理の場合、rescue修飾子を使用してコードをシンプルに保つことができます。

class CacheManager
  def fetch_cached_data(key)
    # キャッシュから値を取得、失敗時はnilを返す
    Rails.cache.read(key) rescue nil
  end

  def safe_parse_json(string)
    # JSON解析、失敗時は空のハッシュを返す
    JSON.parse(string) rescue {}
  end

  def fetch_user_preference(user_id)
    # データベースから設定を取得、失敗時はデフォルト値を返す
    UserPreference.find_by(user_id: user_id)&.settings rescue DEFAULT_SETTINGS
  end
end

テクニック7:raise/rescueによる例外の再送信

エラー情報を保持しながら、より適切な例外に変換して再送信することができます。

class APIGateway
  class << self
    def fetch_user_data(user_id)
      response = make_api_request("/users/#{user_id}")
      process_response(response)
    rescue RestClient::NotFound => e
      # 404エラーを適切な独自例外に変換
      raise UserNotFoundError.new(
        "ユーザーが見つかりません: #{user_id}",
        original_error: e
      )
    rescue RestClient::Unauthorized => e
      # 認証エラーを記録して再送信
      logger.error("API認証エラー: #{e.message}")
      raise AuthenticationError.new(
        "API認証に失敗しました",
        original_error: e
      )
    rescue RestClient::Exception => e
      # その他のAPIエラーを汎用エラーとして扱う
      handle_api_error(e)
    end

    private

    def handle_api_error(error)
      logger.error("APIエラー: #{error.message}")
      logger.error("レスポンス: #{error.response}")

      raise APIError.new(
        "APIリクエストに失敗しました",
        status: error.response.code,
        body: error.response.body,
        original_error: error
      )
    end
  end
end

# カスタムエラークラス
class APIError < StandardError
  attr_reader :status, :body, :original_error

  def initialize(message, status: nil, body: nil, original_error: nil)
    @status = status
    @body = body
    @original_error = original_error
    super(message)
  end
end

これらのテクニックを状況に応じて適切に組み合わせることで、より堅牢なエラーハンドリングを実現できます。

エラーハンドリングのベストプラクティス

無駄な例外処理を避けるための設計指針

効率的なエラーハンドリングを実現するには、適切な設計指針に従うことが重要です。以下に、Rubyでの例外処理における重要な設計原則を示します。

1. 例外の適切な粒度設定

# 悪い例:大きすぎる粒度
def process_order(order)
  begin
    # 全ての処理を1つのbegin-rescue句で囲む
    validate_order(order)
    process_payment(order)
    update_inventory(order)
    send_confirmation(order)
  rescue StandardError => e
    # エラーの種類が特定できない
    logger.error(e)
    raise
  end
end

# 良い例:適切な粒度でエラーハンドリング
class OrderProcessor
  def process_order(order)
    validate_order(order)
    process_payment(order)
    update_inventory(order)
    send_confirmation(order)
  rescue ValidationError => e
    handle_validation_error(e, order)
  rescue PaymentError => e
    handle_payment_error(e, order)
  rescue InventoryError => e
    handle_inventory_error(e, order)
  rescue NotificationError => e
    handle_notification_error(e, order)
  end

  private

  def handle_validation_error(error, order)
    logger.error("注文バリデーションエラー: #{error.message}", order_id: order.id)
    raise OrderError.new("注文の検証に失敗しました", cause: error)
  end

  # 他のエラーハンドラーメソッド...
end

2. 例外の適切な階層構造

# 例外クラスの階層構造の例
module ECommerce
  # 基底例外クラス
  class Error < StandardError; end

  # 機能別の基底例外クラス
  class OrderError < Error; end
  class PaymentError < Error; end
  class InventoryError < Error; end

  # より具体的な例外クラス
  module Order
    class ValidationError < OrderError; end
    class ProcessingError < OrderError; end
  end

  module Payment
    class InsufficientFundsError < PaymentError; end
    class CardDeclinedError < PaymentError; end
  end
end

# 使用例
def process_payment
  validate_payment_info
rescue ECommerce::Payment::InsufficientFundsError => e
  notify_user_insufficient_funds(e)
rescue ECommerce::Payment::CardDeclinedError => e
  request_alternative_payment_method(e)
end

テストの可能性を考慮したrescueの実装方法

エラーハンドリングのテスト容易性を確保するための実装パターンとテスト方法を見ていきましょう。

1. テスト可能なエラーハンドリング設計

# テスト容易性を考慮したサービスクラス
class PaymentService
  class << self
    def process_payment(payment_params)
      new.process_payment(payment_params)
    end
  end

  def process_payment(payment_params)
    validate_params(payment_params)
    charge = create_charge(payment_params)
    record_transaction(charge)
    notify_user(charge)

    { success: true, charge_id: charge.id }
  rescue ValidationError => e
    handle_validation_error(e)
  rescue PaymentProcessingError => e
    handle_processing_error(e)
  end

  private

  def validate_params(params)
    validator = PaymentParamsValidator.new(params)
    raise ValidationError, validator.errors unless validator.valid?
  end

  def create_charge(params)
    payment_gateway.create_charge(params)
  rescue PaymentGateway::Error => e
    raise PaymentProcessingError, e.message
  end

  def handle_validation_error(error)
    log_error(error)
    { success: false, error: error.message, type: :validation_error }
  end

  def handle_processing_error(error)
    log_error(error)
    notify_admin(error)
    { success: false, error: error.message, type: :processing_error }
  end

  def payment_gateway
    @payment_gateway ||= PaymentGateway.new
  end
end

2. エラーハンドリングのテストケース

RSpec.describe PaymentService do
  let(:service) { described_class.new }
  let(:valid_params) do
    {
      amount: 1000,
      currency: 'JPY',
      card_token: 'tok_valid'
    }
  end

  describe '#process_payment' do
    context '正常系' do
      it '支払い処理が成功する' do
        result = service.process_payment(valid_params)

        expect(result[:success]).to be true
        expect(result[:charge_id]).to be_present
      end
    end

    context 'バリデーションエラーの場合' do
      let(:invalid_params) { valid_params.merge(amount: -1) }

      it '適切なエラーレスポンスを返す' do
        result = service.process_payment(invalid_params)

        expect(result[:success]).to be false
        expect(result[:type]).to eq(:validation_error)
        expect(result[:error]).to include('金額が不正です')
      end
    end

    context '決済処理エラーの場合' do
      before do
        allow(service.send(:payment_gateway))
          .to receive(:create_charge)
          .and_raise(PaymentGateway::Error.new('カード決済に失敗しました'))
      end

      it '適切なエラーレスポンスを返す' do
        result = service.process_payment(valid_params)

        expect(result[:success]).to be false
        expect(result[:type]).to eq(:processing_error)
        expect(result[:error]).to include('カード決済に失敗しました')
      end

      it '管理者に通知する' do
        expect(service)
          .to receive(:notify_admin)
          .with(instance_of(PaymentProcessingError))

        service.process_payment(valid_params)
      end
    end
  end
end

3. モック/スタブを活用したテスト

RSpec.describe OrderProcessor do
  let(:processor) { described_class.new }
  let(:order) { build(:order) }

  # 外部サービスのモック
  let(:payment_service) { instance_double('PaymentService') }
  let(:inventory_service) { instance_double('InventoryService') }
  let(:notification_service) { instance_double('NotificationService') }

  before do
    allow(processor).to receive(:payment_service).and_return(payment_service)
    allow(processor).to receive(:inventory_service).and_return(inventory_service)
    allow(processor).to receive(:notification_service).and_return(notification_service)
  end

  describe '#process_order' do
    context '支払い処理でエラーが発生した場合' do
      before do
        allow(payment_service)
          .to receive(:process)
          .and_raise(PaymentError.new('支払い処理に失敗しました'))
      end

      it 'エラーを適切にハンドリングする' do
        expect { processor.process_order(order) }
          .to change { order.status }
          .to('payment_failed')
      end

      it 'ログを記録する' do
        expect(Rails.logger)
          .to receive(:error)
          .with(/支払い処理に失敗しました/)

        processor.process_order(order)
      end
    end
  end
end

これらのベストプラクティスと実装パターンを活用することで、保守性が高く、テストが容易なエラーハンドリングを実現できます。

実際のプロジェクトでよくあるエラーとその対処法

データベース操作時の例外処理パターン

データベース操作では、様々な例外が発生する可能性があります。以下に主要なエラーパターンと対処法を示します。

class DatabaseOperationService
  class << self
    def safe_transaction
      ActiveRecord::Base.transaction do
        yield
      rescue ActiveRecord::RecordNotFound => e
        handle_not_found_error(e)
      rescue ActiveRecord::RecordInvalid => e
        handle_validation_error(e)
      rescue ActiveRecord::StaleObjectError => e
        handle_optimistic_lock_error(e)
      rescue ActiveRecord::StatementInvalid => e
        handle_sql_error(e)
      rescue ActiveRecord::ConnectionTimeoutError => e
        handle_connection_timeout(e)
      end
    end

    private

    def handle_not_found_error(error)
      Rails.logger.error("データが見つかりません: #{error.message}")
      raise EntityNotFoundError.new("要求されたリソースが見つかりません", original_error: error)
    end

    def handle_validation_error(error)
      Rails.logger.error("バリデーションエラー: #{error.record.errors.full_messages}")
      raise ValidationError.new("データの検証に失敗しました", details: error.record.errors)
    end

    def handle_optimistic_lock_error(error)
      Rails.logger.error("楽観的ロックエラー: #{error.message}")
      raise ConcurrencyError.new("データが他のユーザーによって更新されています")
    end

    def handle_sql_error(error)
      Rails.logger.error("SQLエラー: #{error.message}")
      if error.message.include?('duplicate key value')
        raise DuplicateRecordError.new("既に同じデータが存在します")
      else
        raise DatabaseError.new("データベース操作に失敗しました")
      end
    end

    def handle_connection_timeout(error)
      Rails.logger.error("DB接続タイムアウト: #{error.message}")
      raise DatabaseConnectionError.new("データベースとの接続がタイムアウトしました")
    end
  end
end

# 使用例
class OrderProcessor
  def process_bulk_orders(orders)
    DatabaseOperationService.safe_transaction do
      orders.each do |order|
        order.with_lock do
          process_single_order(order)
        end
      rescue OrderError => e
        handle_order_error(order, e)
      end
    end
  end

  private

  def process_single_order(order)
    update_inventory(order)
    update_order_status(order)
    create_shipment(order)
  end

  def handle_order_error(order, error)
    order.update_columns(
      status: :processing_failed,
      error_message: error.message
    )
    notify_admin_of_failure(order, error)
  end
end

外部APIと連携時のエラーハンドリング

外部APIとの連携では、ネットワークエラーやタイムアウトなど、様々な例外が発生する可能性があります。

class APIClient
  class << self
    def with_retry(max_retries: 3, base_wait_time: 1)
      retries = 0
      begin
        yield
      rescue Net::OpenTimeout, Net::ReadTimeout => e
        handle_timeout_error(e, retries, max_retries, base_wait_time)
      rescue Faraday::ConnectionFailed => e
        handle_connection_error(e, retries, max_retries, base_wait_time)
      rescue Faraday::ClientError => e
        handle_client_error(e)
      rescue Faraday::ServerError => e
        handle_server_error(e)
      end
    end

    private

    def handle_timeout_error(error, retries, max_retries, base_wait_time)
      if retries < max_retries
        wait_time = calculate_backoff(base_wait_time, retries)
        log_retry_attempt(error, retries, wait_time)
        sleep(wait_time)
        retries += 1
        retry
      else
        raise APITimeoutError.new("APIリクエストがタイムアウトしました", original_error: error)
      end
    end

    def handle_connection_error(error, retries, max_retries, base_wait_time)
      if retries < max_retries
        wait_time = calculate_backoff(base_wait_time, retries)
        log_retry_attempt(error, retries, wait_time)
        sleep(wait_time)
        retries += 1
        retry
      else
        raise APIConnectionError.new("API接続に失敗しました", original_error: error)
      end
    end

    def handle_client_error(error)
      case error.response[:status]
      when 401
        raise APIAuthenticationError.new("API認証に失敗しました")
      when 403
        raise APIAuthorizationError.new("APIアクセスが拒否されました")
      when 404
        raise APIResourceNotFoundError.new("APIリソースが見つかりません")
      else
        raise APIClientError.new("APIクライアントエラーが発生しました", status: error.response[:status])
      end
    end

    def handle_server_error(error)
      raise APIServerError.new("APIサーバーエラーが発生しました", status: error.response[:status])
    end

    def calculate_backoff(base_time, retry_count)
      base_time * (2 ** retry_count) + rand(0.1..0.5)
    end

    def log_retry_attempt(error, retries, wait_time)
      Rails.logger.warn("APIリトライ実行 (#{retries + 1}回目): #{error.message}. #{wait_time}秒後に再試行")
    end
  end
end

# 実装例
class PaymentGateway
  def process_payment(payment)
    APIClient.with_retry do
      response = api_client.post('/payments', payment.to_json)
      handle_payment_response(response)
    end
  rescue APIError => e
    handle_payment_error(e, payment)
  end

  private

  def handle_payment_response(response)
    case response.status
    when 200..299
      process_successful_payment(response.body)
    else
      raise PaymentProcessingError.new("支払い処理に失敗しました", response: response)
    end
  end

  def handle_payment_error(error, payment)
    payment.update(status: :failed, error_message: error.message)
    notify_payment_failure(payment, error)
    raise PaymentError.new("支払い処理中にエラーが発生しました", original_error: error)
  end
end

並行処理における例外処理の注意点

並行処理を行う際は、スレッドやプロセス間でのエラーハンドリングに特に注意が必要です。

class BatchProcessor
  class << self
    def process_in_parallel(items, max_threads: 5)
      results = { successful: [], failed: [] }
      mutex = Mutex.new
      error_queue = Queue.new

      ThreadsWait.all(*items.each_slice(items.size / max_threads).map { |batch|
        Thread.new do
          begin
            process_batch(batch, mutex, results)
          rescue StandardError => e
            error_queue << e
          end
        end
      })

      handle_thread_errors(error_queue)
      results
    end

    private

    def process_batch(items, mutex, results)
      items.each do |item|
        begin
          processed_item = process_single_item(item)
          mutex.synchronize { results[:successful] << processed_item }
        rescue StandardError => e
          mutex.synchronize {
            results[:failed] << {
              item: item,
              error: e.message,
              backtrace: e.backtrace&.first(5)
            }
          }
          log_processing_error(item, e)
        end
      end
    end

    def process_single_item(item)
      # 具体的な処理をここに実装
      raise NotImplementedError
    end

    def handle_thread_errors(error_queue)
      errors = []
      until error_queue.empty?
        errors << error_queue.pop
      end

      return if errors.empty?

      raise BatchProcessingError.new(
        "バッチ処理中に#{errors.size}件のエラーが発生しました",
        errors: errors
      )
    end

    def log_processing_error(item, error)
      Rails.logger.error(
        "項目処理エラー: #{error.message}\n" \
        "項目: #{item.inspect}\n" \
        "バックトレース: #{error.backtrace&.first(5)&.join("\n")}"
      )
    end
  end
end

# 使用例
class ImageProcessor < BatchProcessor
  class << self
    private

    def process_single_item(image)
      validate_image(image)
      resized_image = resize_image(image)
      upload_to_storage(resized_image)
      update_image_status(image)
    rescue ImageValidationError => e
      raise ImageProcessingError.new("画像の検証に失敗しました", image: image, cause: e)
    rescue ImageResizeError => e
      raise ImageProcessingError.new("画像のリサイズに失敗しました", image: image, cause: e)
    rescue StorageError => e
      raise ImageProcessingError.new("画像のアップロードに失敗しました", image: image, cause: e)
    end
  end
end

これらのパターンと注意点を適切に組み合わせることで、より堅牢なエラーハンドリングを実現できます。