【 ruby-openai 】エラーハンドリングをカスタマイズしたいゴリラの備忘録

# 初めに
皆さん、お疲れさマッチョです！💪
　
今回、私が個人開発したWebアプリ『トレゴリ』についてユーザー様からフィードバックをいただき、急ぎ対処する必要があったとともに少々問題内容の理解に手こずってしまったため、備忘録として記事を作成しました。
　

:::note warn
**注意点**

今回の記事に関して、一部内容に誤りがある場合がございます事をご了承下さい。
もし誤りに気づかれた際は、コメント等でご教授いただけると助かります🙇‍♂️
:::

# アプリ機能について
まずエラー内容についてお話しする前に、私が開発したアプリ内容について少々ご説明の方させていただきたいと思います。
　
私が開発した『トレゴリ』は、大きく以下の機能があります。
　
- ホーム画面の入力フォームにて運動内容を入力 → 運動内容がデータベースに保存され、バナナを1本取得する（運動内容の入力・バナナの取得に関しては1日1回のみ）
　
- 運動内容を含むプロンプトを生成し、OpenAI APIへリクエストを送信 → 運動内容に対する労いの言葉を生成し、レスポンスを返す。
　
- 取得したバナナを5本集めることで、ガチャを一回引く事ができる。
　
- ガチャを一回引くことに、ランダムにゴリラのイラストを取得する事が出来る。

　
今回は、上記機能①、②に関してユーザー様から不具合のフィードバックをいただいたので、不具合の内容と対処方法についてお話しさせていただきます。

# 今回の不具合内容
今回、頂いたフィードバック内容としましては「運動内容を入力してリクエストを送信した際、例外処理が発生してしまい、レスポンスが返ってこなかった。しかし、運動内容の保存とバナナの取得が出来てしまっている。」と言うものでした。
　
また、今回頂いたフィードバック内容についてはHTTPステータスコードが500番台でのエラーだったのですが、400番台エラーに関しても同様の不具合が発生する事が確認されました。
　
そのため今回は、HTTPステータスコードに応じた処理内容を定義するために調べた内容を説明していきたいと思います。

# 手順と説明

## 1. コントローラーの修正
まず最初に、コントローラー内でトランザクションを使用して、OpenAI APIからのレスポンスが成功した場合のみデータを保存するように変更する必要がありました。
　
変更前のロジックは以下の通りです。
```ruby:controller.rb
class TrainingRecordsController < ApplicationController

  .
  .
  .

  def create
    if TrainingRecord.check_report?(current_user)
      flash[:warning] = t(".reported")
      redirect_to training_records_path
    else 
      @record = current_user.training_records.build(record_params)
      if @record.save
        @record.update(bot_content: generate_openai_compliment(@record.sport_content))
        current_user.number_of_banana.increment!(:count, 1)
        redirect_to training_reports_path(record_id: @record.id)
      else
        redirect_to root_path
      end
    end
  end

  .
  .
  .

  private

  def record_params
    params.require(:training_record).permit(:sport_content, :start_time)
  end

  def generate_openai_compliment(sport_content)
    OpenaiComplimentGenerator.generate_compliment(sport_content)
  end
end
```

これを下記の通りに変更しました。
`create` アクションのみ変更しております。
```ruby:controller.rb
class TrainingRecordsController < ApplicationController

  .
  .
  .

  def create
    if TrainingRecord.check_report?(current_user)
      flash[:warning] = t(".reported")
      redirect_to training_records_path
    else 
      @record = current_user.training_records.build(record_params)
      begin
        ActiveRecord::Base.transaction do
          @record.save!
          bot_content = generate_openai_compliment(@record.sport_content)
          @record.update!(bot_content: bot_content)
          current_user.number_of_banana.increment!(:count, 1)
        end
        redirect_to training_reports_path(record_id: @record.id)
      rescue OpenaiComplimentGenerator::OpenAIError => e
        flash[:alert] = "#{e.message}"
        redirect_to training_records_path
      rescue => e
        flash[:alert] = "保存に失敗しました。管理者にお問い合わせ下さい。 "
        redirect_to training_records_path
      end
    end
  end

  .
  .
  .

  private

  def record_params
    params.require(:training_record).permit(:sport_content, :start_time)
  end

  def generate_openai_compliment(sport_content)
    OpenaiComplimentGenerator.generate_compliment(sport_content)
  end
end
```

上記のように、 **`ActiveRecord::Base.transaction do … end`** を用いて、そのブロック内で『**運動内容の保存処理**』『**OpenAI APIへのリクエスト送信**』『**レスポンスの取得**』などを定義しておく事で、どこか一つでも例外処理が発生した場合に、全ての処理を中断してロールバック（元に戻す）するようにしています。
　
こうする事で、OpenAI APIからのレスポンスが返ってこなかった場合でも、「**運動内容が保存される**」「**バナナを取得出来てしまう**」といった処理をせずに中断させる事が出来ます。

発生した例外については、例外オブジェクトとして **`rescue`** 節によって捕捉されるので、発生した例外に応じたフラッシュメッセージを表示させるようにしています。

　
ちなみに、 **`rescue`** 節で参照している **`OpenaiComplimentGenerator::OpenAIError`** と言う例外クラスについては後ほど説明します。

## 2. HTTPステータスコードに応じたエラーハンドリングの実装
HTTPステータスコードに応じて表示するエラーメッセージを変更するためには、ステータスコードを確認し、それに基づいて適切なエラーメッセージを設定するロジックを実装する必要がありました。
　
上記処理のロジックに関しては、APIリクエストの処理を行うサービスレイヤー`openai_compliment_generator.rb`内に定義する事としました。

調べてみたところ、APIリクエスト時のエラーハンドリングの定義は、実際に処理を行うサービスレイヤー内で定義するのが一般的みたいです🤔

　
と言う事で、サービスレイヤー内にロジックを追加していきマッチョ！
　
下記は変更前のコードになります。
```ruby:openai_compliment_generator.rb
require 'ruby/openai'

class OpenaiComplimentGenerator
  def self.generate_compliment(sport_content)
    client = OpenAI::Client.new
    prompt = "運動内容：『#{sport_content}』"

    response = client.chat(
      parameters: {

        .
        .
        .

        messages: [{ role: 'user', content: prompt }],
      }
    )

    response.dig('choices', 0, 'message', 'content')
  end
end
```
　
これにロジックを追加した内容が、下記となります。
```ruby:openai_compliment_generator.rb
require 'ruby/openai'

class OpenaiComplimentGenerator
  # 仮想のOpenAIクライアントライブラリの例外クラスを定義
  class OpenAIError < StandardError; end

  def self.generate_compliment(sport_content)
    client = OpenAI::Client.new
    prompt = "運動内容：『#{sport_content}』"

    begin
      response = client.chat(
        parameters: {

        .
        .
        .

        messages: [{ role: 'user', content: prompt }],
        }
      )
      # レスポンスが問題なく返ってきているかをチェック
      if response.key?('choices') && response['choices'].any?
        response.dig('choices', 0, 'message', 'content')
      else
        raise OpenAIError, "予期せぬレスポンス形式です。"
      end
    rescue Faraday::ClientError => e
      handle_faraday_error(e)
    rescue Faraday::ServerError => e
      handle_faraday_error(e)
    rescue Faraday::Error => e
      raise OpenAIError, "通信エラーが発生しました: #{e.message}"
    end
  end

  def self.handle_faraday_error(e)
    status = e.response[:status]
    case status
    when 400
      raise OpenAIError, "リクエストが不正です。入力内容を確認してください。"
    when 401
      raise OpenAIError, "認証に失敗しました。管理者にお問い合わせ下さい。"
    when 403..404
      raise OpenAIError, "リクエスト先のページが存在しません。URLを確認して下さい。"
    when 408
      raise OpenAIError, "リクエストがタイムアウトしました。しばらくしてから再試行してください。"
    when 500..599
      raise OpenAIError, "サーバー側の問題が発生しました。しばらくしてから再試行してください。"
    else
      raise OpenAIError, "予期せぬエラーが発生しました。ステータスコード: #{status}"
    end
  end
end
```

上記について順に説明していきます。
　
### カスタム例外クラスの定義
まず下記部分で、 **`StandardError`** クラスを継承した **`OpenAIError`** を定義しています。
```ruby
class OpenAIError < StandardError; end
```

さて、ここでこう思われた方もいらっしゃるのではないでしょうか？
　
「 **`StandardError`** クラスそのまま使えば良くない？」
　
はい、ごもっともです笑
ただ今回は、OpenAI APIに関する例外が発生した事を容易に把握出来るようにするために、**`StandardError`** クラスを継承した **`OpenAIError`** を定義しています。

　
まぁ実際は **`StandardError`** クラスを使用するので問題ないのですが、今回は学習も兼ねてという事で.....笑
　
### レスポンスの確認
```ruby
if response.key?('choices') && response['choices'].any?
  response.dig('choices', 0, 'message', 'content')
else
  raise OpenAIError, "予期せぬレスポンス形式です。"
end
```
ここでは、OpenAI APIからレスポンスが問題なく返ってきているか否かをif文で条件分岐を行っています。
　
レスポンスが返ってきていれば、そこから特定の値を取り出す。
そうでなければ **`OpenAIError`** 例外を発生させるようにロジックを組んでいます。
　
### Faradayによる例外の捕捉
```ruby
rescue Faraday::ClientError => e
  handle_faraday_error(e)
rescue Faraday::ServerError => e
  handle_faraday_error(e)
rescue Faraday::Error => e
  raise OpenAIError, "通信エラーが発生しました: #{e.message}"
end
```
ここでは、 **`ruby-openai`** Gemを使用してOpenAI APIにリクエストを送信する際に、発生する可能性のある **Faraday** に関連するエラーを捕捉し、適切に処理するためのエラーハンドリングのロジックを組んでいます。

　
「 **Faraday** って何ぞや....？」
と言う方に少し簡単に説明しますと....


:::note info
**Faraday** とは
Faradayとは、Rubyで書かれたHTTPクライアントライブラリの事です。
RailsやRubyから外部APIにリクエストを送信したり、外部APIからのレスポンスを受け取るのに使用されています。
:::

今回使用している **`ruby-openai`** にはこの**Faraday**が既に組み込まれているため、特に**Faraday**に関して理解せずとも良い感じにOpenAI APIとの通信を行ってくれるので、便利と言えば便利です。
　
が....
これはOpenAI APIのみに特化しているHTTPクライアントライブラリなので、その他の外部APIとの通信には使用できません。

また、エラーハンドリングやリクエスト/レスポンスの処理方法についてのカスタマイズ性があまり高く無いので、実務においては**Faraday** を使用するのが無難だと感じました....💦

　

話に戻りますが、先ほどのコードではリクエスト/レスポンス中に発生した例外を **`rescue`** で捕捉して、捕捉した例外クラスに応じた処理を呼び出すようにしています。
　
処理内容についてざっくり説明すると....
　
- **`Faraday::ClientError`** ・ **`Faraday::ServerError`** ：エラーオブジェクトを取得し、引数として **`handle_faraday_error`** メソッドを呼び出す
- `**Faraday::Error**` ：カスタム例外クラス **`OpenAIError`** を発生させる

　
例外クラスの内容に関しては以下の通りです。
|クラス名|説明|
|:-----------|:------------|
|Faraday::ClientError|クライアントからのリクエストに問題がある場合に発生。<br><br>無効なリクエスト・認証失敗・見つからないURLへのリクエストなどの、400番台のステータスコードに対応するエラーが含まれている。|
|Faraday::ServerError|サーバー側で問題が発生した場合に発生。<br><br>サーバー内部のエラー・サービスの利用不可など、500番台のステータスコードに対応するエラーが含まれている。|
|Faraday::Error|Faradayでの操作中に発生する様々なエラーのスーパークラス。<br>基本的には、Faradayに関連するエラーを広範囲に捕捉するために使用される。<br><br>今回の場合だと、《**`Faraday::ClientError`**》《**`Faraday::ServerError`**》で捕捉仕切れない例外を捕捉するために定義している。|


### エラーハンドリングの定義
```ruby
def self.handle_faraday_error(e)
  status = e.response[:status]
  case status
  when 400
    raise OpenAIError, "リクエストが不正です。入力内容を確認してください。"
  when 401
    raise OpenAIError, "認証に失敗しました。管理者にお問い合わせ下さい。"
  when 403..404
    raise OpenAIError, "リクエスト先のページが存在しません。URLを確認して下さい。"
  when 408
    raise OpenAIError, "リクエストがタイムアウトしました。しばらくしてから再試行してください。"
  when 429
    raise OpenAIError, "リクエストがレート/トークン上限を超えています。管理者にお問い合わせ下さい。"
  when 500..599
    raise OpenAIError, "サーバー側の問題が発生しました。しばらくしてから再試行してください。"
  else
    raise OpenAIError, "予期せぬエラーが発生しました。ステータスコード: #{status}"
  end
end
```

ここでは、先ほど引数として受け取ったエラーオブジェクト (**`e`**) からHTTPステータスコードを取得し、ステータスコードの内容に応じた **`OpenAIError`** 例外を発生させる条件分岐を組んでいます。

HTTPステータスコードの取得に関しては、**Faraday** にて定義されているレスポンスオブジェクトの扱い方の一つである **`response[:status]`** を使用して取得するようにしています。

　
今回のように、「400番台の場合は〇〇」「500番台の場合は□□」のような、条件が複数存在する条件分岐を定義したい場合、 **`case`** 文を用いる事で定義する事が出来ますので、是非覚えておいてください💪

　
で、ここで発生した **`OpenAIError`** がcreateアクションの下記 **`rescue`** 部分で捕捉され、フラッシュメッセージとして表示する事が出来るという訳です。
```ruby
rescue OpenaiComplimentGenerator::OpenAIError => e
  flash[:alert] = "#{e.message}"
  redirect_to training_records_path
```
　
# 実装中に沼ってしまった話
ここからは、わたくしゴリラがHTTPステータスコードに応じたエラーハンドリングの実装中に沼ってしまった部分について、お話ししていきたいと思いマッチョ。

　
最初、エラーハンドリングの実装する際にロジックを下記のように記述していました。
```ruby:openai_compliment_generator.rb
require 'ruby/openai'

class OpenaiComplimentGenerator
  
		.
		.
		.
		.
		.

    if response.success?
      response.dig('choices', 0, 'message', 'content')
    else
      handle_error(response)
    end
  end

  def self.handle_error(response)
    case response.status
    when 400
      raise OpenAIError, "リクエストが不正です。入力内容を確認してください。"
    when 401
      raise OpenAIError, "認証に失敗しました。開発元にお問い合わせ下さい。"
    when 403..404
      raise OpenAIError, "リクエスト先のページが存在しません。URLを確認して下さい。"
    when 408
      raise OpenAIError, "リクエストがタイムアウトしました。しばらくしてから再試行してください。"
    when 500..599
      raise OpenAIError, "サーバー側の問題が発生しました。しばらくしてから再試行してください。"
    else
      raise OpenAIError, "予期せぬエラーが発生しました。ステータスコード: #{response.status}"
    end
  end
end
```

まず、上記の **`if response.success?`** 部分ですが、これだと (**`undefined method success?`** ) エラーを吐いちゃいます....💦
　
**`success?`** メソッドについて説明すると....

:::note info
**success?** とは
`success?` とは、HTTPレスポンスオブジェクトに対して、レスポンスが成功（HTTPステータスコードが200番台）を示しているかどうかを確認するために使われるメソッド。
:::

しかし、今回使用しているクライアントライブラリは **`ruby-openai`** なので、OpenAI APIからのレスポンスオブジェクトの形式は、《**Hashオブジェクト**》 として返される事となります。

　
なんと....！

《**Hashオブジェクト**》 はキーと値のペアを格納するためのRubyの基本的なデータ構造であって、HTTPレスポンスのステータスを確認するためのメソッドは含まれてないんです....！

:::note warn
**つまり....**
`ruby-openai` を使用している場合、レスポンスから直にHTTPステータスコードを取得する事が出来ない！と言うことになります。
:::

　
いやぁ、正直どうしようかと悩みまくって大胸筋まで沼に浸かってました。

んで一旦ジム行って脚トレして頭空っぽにして、再度悩みに悩みまくった結果....

　
**「例外を発生・捕捉してからエラーオブジェクトを取得して、そこからHTTPエラーステータスコードを取得するようにすればいけるんか....？」**

　
と言う結論に至りました。

で、少々手間ですが『 **例外を発生→捕捉→エラーオブジェクトの取得** 』と一連の動作を挟んでHTTPステータスコードを取得する事としました。

# 最後に
はい、という事で今回は、HTTPステータスコードに応じた処理内容の定義についてOpenAI APIを使用している場合を参考に説明させていただきました。

例外処理については、正直曖昧な知識のままで進めていた部分もあったので、今回の不具合内容を機にしっかりキャッチアップ出来て良かったと思っています💪
　
ちなみに、Faradayに関してはまだまだ理解不足なので、この辺りについてもまた合間で記事を書こうと思います....！

　

最後に、今回の記事が同じように例外処理のロジックについて困っている方の手助けになれれば幸いです。

最後まで読んでいただき、誠にありがとうございマッチョでした🦍