12
12

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

Last updated at Posted at 2024-02-11

初めに

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

注意点

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

アプリ機能について

まずエラー内容についてお話しする前に、私が開発したアプリ内容について少々ご説明の方させていただきたいと思います。
 
私が開発した『トレゴリ』は、大きく以下の機能があります。
 

  • ホーム画面の入力フォームにて運動内容を入力 → 運動内容がデータベースに保存され、バナナを1本取得する(運動内容の入力・バナナの取得に関しては1日1回のみ)
     
  • 運動内容を含むプロンプトを生成し、OpenAI APIへリクエストを送信 → 運動内容に対する労いの言葉を生成し、レスポンスを返す。
     
  • 取得したバナナを5本集めることで、ガチャを一回引く事ができる。
     
  • ガチャを一回引くことに、ランダムにゴリラのイラストを取得する事が出来る。

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

今回の不具合内容

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

手順と説明

1. コントローラーの修正

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

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 アクションのみ変更しております。

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リクエスト時のエラーハンドリングの定義は、実際に処理を行うサービスレイヤー内で定義するのが一般的みたいです🤔

 
と言う事で、サービスレイヤー内にロジックを追加していきマッチョ!
 
下記は変更前のコードになります。

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

 
これにロジックを追加した内容が、下記となります。

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 を定義しています。

class OpenAIError < StandardError; end

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

 
まぁ実際は StandardError クラスを使用するので問題ないのですが、今回は学習も兼ねてという事で.....笑
 

レスポンスの確認

if response.key?('choices') && response['choices'].any?
  response.dig('choices', 0, 'message', 'content')
else
  raise OpenAIError, "予期せぬレスポンス形式です。"
end

ここでは、OpenAI APIからレスポンスが問題なく返ってきているか否かをif文で条件分岐を行っています。
 
レスポンスが返ってきていれば、そこから特定の値を取り出す。
そうでなければ OpenAIError 例外を発生させるようにロジックを組んでいます。
 

Faradayによる例外の捕捉

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 って何ぞや....?」
と言う方に少し簡単に説明しますと....

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

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

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

 

話に戻りますが、先ほどのコードではリクエスト/レスポンス中に発生した例外を rescue で捕捉して、捕捉した例外クラスに応じた処理を呼び出すようにしています。
 
処理内容についてざっくり説明すると....
 

  • Faraday::ClientErrorFaraday::ServerError :エラーオブジェクトを取得し、引数として handle_faraday_error メソッドを呼び出す
  • **Faraday::Error** :カスタム例外クラス OpenAIError を発生させる

 
例外クラスの内容に関しては以下の通りです。

クラス名 説明
Faraday::ClientError クライアントからのリクエストに問題がある場合に発生。

無効なリクエスト・認証失敗・見つからないURLへのリクエストなどの、400番台のステータスコードに対応するエラーが含まれている。
Faraday::ServerError サーバー側で問題が発生した場合に発生。

サーバー内部のエラー・サービスの利用不可など、500番台のステータスコードに対応するエラーが含まれている。
Faraday::Error Faradayでの操作中に発生する様々なエラーのスーパークラス。
基本的には、Faradayに関連するエラーを広範囲に捕捉するために使用される。

今回の場合だと、《Faraday::ClientError》《Faraday::ServerError》で捕捉仕切れない例外を捕捉するために定義している。

エラーハンドリングの定義

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 部分で捕捉され、フラッシュメッセージとして表示する事が出来るという訳です。

rescue OpenaiComplimentGenerator::OpenAIError => e
  flash[:alert] = "#{e.message}"
  redirect_to training_records_path

 

実装中に沼ってしまった話

ここからは、わたくしゴリラがHTTPステータスコードに応じたエラーハンドリングの実装中に沼ってしまった部分について、お話ししていきたいと思いマッチョ。

 
最初、エラーハンドリングの実装する際にロジックを下記のように記述していました。

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? メソッドについて説明すると....

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

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

 
なんと....!

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

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

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

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

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

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

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

最後に

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

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

 

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

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

12
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
12