👋

RailsでOpenAI APIを使おう【Faraday / エラーハンドリング】

2023/12/09に公開

昨今AI分野ではPythonを使うのが主流になっていますが、今回はRuby on RailsアプリにChatGPTを導入してみました。

特に、実運用で使うことを想定して、エラーハンドリングについて真面目に考察しました。

この記事で紹介しているコードは下記のGitHubレポジトリで公開しています。

https://github.com/yoiyoicho/zenn/tree/main/examples/chatgpt_rails

もし気になる点がありましたらご指摘いただけるとありがたいです!

環境

  • Ruby on Rails 7.1.2
  • faraday 2.7.12

使用ライブラリの選定

OpenAI APIの公式ドキュメントはこちら。

https://platform.openai.com/docs/api-reference

RailsからOpenAI APIを利用する一番シンプルな実装方法としては、まずnet/httpなどのHTTPクライアント機能を持つ標準ライブラリを使うことが考えられます。

また、同じくHTTPクライアント機能を有するライブラリFaradayも、拡張性が高く人気です。

https://github.com/lostisland/faraday

https://lostisland.github.io/faraday/#/

さらに最も簡便な手法としては、非公式に開発されている下記のruby-openaiというライブラリを使う手もあります。

https://github.com/alexrudall/ruby-openai

コードの簡潔さ、カスタマイズ性、メンテナンス性などを考慮するとそれぞれ一長一短あると思いますが、今回は認証やタイムアウトの機能を追加しやすいfaradayを使うことにしました。

というのも、実際にOpenAI APIを叩いてみると、500エラーがまあまあの頻度で起こったり、リクエストが複雑化するとレスポンスにものすごく時間がかかったり、日本語でリクエストを送ったのに文字化けしたレスポンスが帰ってくるなど、ChatGPTをコンソールで使用するときと同じような動作の不安定さが気になったからです。

よってOpenAI APIを利用する際は適切にエラーハンドリングできる環境が大事だと感じ、ミドルウェアを使ってロギングやタイムアウトなどの追加機能を簡単に統合できるFaradayが適していると結論づけました。

ちなみに、先にあげたruby-openaiも内部的にはFaradayを使っていました!

実装とポイント

ではここから、RailsアプリにChatGPTを組み込んでいきます。作成する機能は、ユーザーがメッセージを送るとChatGPTからレスポンスが帰ってくるというシンプルなものです。なお、RailsアプリはAPIモードで作成しました。

OpenAI APIキーの発行

まず、OpenAI APIの利用に必要なAPIキーをOpenAIのプラットフォームで発行します。発行方法は下記の記事が参考になりました。

https://zenn.dev/umi_mori/books/chatbot-chatgpt/viewer/how_to_use_openai_api#apiの発行方法

faradayのインストール

次に、Railsにfaradayをインストールします。環境変数の管理にdotenv-railsを使うので一緒にインストールします。

Gemfile
gem 'faraday'
gem 'dotenv-rails'
$ bundle install

環境変数OPENAI_API_KEYの設定

.envファイルを作成し、OPENAI_API_KEYに、先ほど取得したOpenAI APIキーの値を設定します。

.env
OPENAI_API_KEY='your api key'

サービスオブジェクトの作成

OpenAI APIと通信するクラスの設計には、サービスオブジェクトを採用しました。/app配下に/services/openai/ディレクトリを作成し、そこに一連の処理をまとめます。

app/services/openai/base_service.rb
module Openai
  class UnauthorizedError < StandardError; end
  class TooManyRequestsError < StandardError; end
  class InternalServerError < StandardError; end
  class ServiceUnavailableError < StandardError; end
  class TimeoutError < StandardError; end

  class BaseService
    attr_reader :model

    def initialize(model: 'gpt-3.5-turbo', timeout: 10)
      @model = model
      @connection = Faraday.new(url: 'https://api.openai.com') do |f| # point1
        f.headers['Authorization'] = "Bearer #{ENV['OPENAI_API_KEY']}"
        f.headers['Content-Type'] = 'application/json'
        f.options[:timeout] = timeout
        f.adapter Faraday.default_adapter
      end
    end

    protected

    def post_request(url: '/', body: '{}')
      response = @connection.post(url) { |req| req.body = body }
      handle_response_errors(response)
      response
    rescue Faraday::TimeoutError
      raise TimeoutError, 'リクエストがタイムアウトしました。もう一度お試しください。'
    end

    private

    def handle_response_errors(response) # point2
      case response.status
      when 200
      when 401
        raise UnauthorizedError, extract_message(response.body)
      when 429
        raise TooManyRequestsError, extract_message(response.body)
      when 500
        raise InternalServerError, extract_message(response.body)
      when 503
        raise ServiceUnavailableError, extract_message(response.body)
      else
        raise StandardError, '不明なエラーです。'
      end
    end

    def extract_message(response_body)
      extracted_message = begin
                            response_json = JSON.parse(response_body)
                            return nil unless response_json.is_a?(Hash)

                            response_json.dig("error", "message")
                          rescue JSON::ParserError
                            nil
                          end
      extracted_message || 'エラーが発生しましたが、エラーメッセージが取得できませんでした。'
    end
  end
end
app/services/openai/chat_response_service.rb
module Openai
  class ChatResponseService < BaseService
    def call(input)
      body = build_body(input)
      response = post_request(url: '/v1/chat/completions', body: body)
      extract_message_content(response)
    end

    private

    def build_body(input)
      {
        model: @model,
        messages: [{ role: "user", content: input }]
      }.to_json
    end

    def extract_message_content(response)
      response_hash = JSON.parse(response.body)
      content = response_hash.dig("choices", 0, "message", "content")
      raise StandardError, 'チャットの返信が取得できませんでした。' unless content.present?

      content
    rescue JSON::ParserError
      raise StandardError, 'チャットの返信が取得できませんでした。'
    end
  end
end

エラーハンドリングのポイントは次のとおりです。

point1 タイムアウト

Openai::BaseServiceクラスのインスタンス作成時に、Faradayのコネクションを作成し、後続の処理で使えるようにします。このときにAuthorizationヘッダーとタイムアウト時間を設定しました。

def initialize(model: 'gpt-3.5-turbo', timeout: 10)
  @model = model
  @connection = Faraday.new(url: 'https://api.openai.com') do |f|
    f.headers['Authorization'] = "Bearer #{ENV['OPENAI_API_KEY']}"
    f.headers['Content-Type'] = 'application/json'
    f.options[:timeout] = timeout
    f.adapter Faraday.default_adapter
  end
end

タイムアウト時間内にリクエストが完了しない場合、Faraday::TimeoutError例外が発生します。これは下記の箇所で拾っています。

def post_request(url: '/', body: '{}')
  response = @connection.post(url) { |req| req.body = body }
  handle_response_errors(response)
  response
rescue Faraday::TimeoutError
  raise TimeoutError, 'リクエストがタイムアウトしました。もう一度お試しください。'
end

これでOpenAI APIに何らかの不具合が起きているときでも、レスポンスを無限に待機して無駄にリソースを消費することを防げます。

ちなみに、このようにFaradayにミドルウェアを追加するときは、最後にアダプタを必ず指定しなければならないので注意です!

https://nekorails.hatenablog.com/entry/2018/09/28/152745#013-アダプタを使う

point2 エラーステータスコード

OpenAI APIから返ってくる可能性のあるエラーステータスコードは下記の通り。

https://platform.openai.com/docs/guides/error-codes

特に429のリクエスト制限、500の(OpenAI API側の)サーバーエラーあたりは遭遇する可能性が高いです。

こちらに基づき、レスポンスが返ってきたらそのステータスを評価し、エラーステータスコードに合致するときはカスタム例外を発生させています。

def handle_response_errors(response)
  case response.status
  when 200
  when 401
    raise UnauthorizedError, extract_message(response.body)
  when 429
    raise TooManyRequestsError, extract_message(response.body)
  when 500
    raise InternalServerError, extract_message(response.body)
  when 503
    raise ServiceUnavailableError, extract_message(response.body)
  else
    raise StandardError, '不明なエラーです。'
  end
end

こちらの実装ですが、タイムアウトにより発生する例外とエラーステータスコードにより発生する例外が異なる場所に存在しているのがちょっと見にくいかなと感じています。

Faradayにはエラーステータスコードが発生したときに例外を起こすraise_errorというミドルウェアもあるので、こちらを使ったパターンを検討してもいいかもしれません。

https://qiita.com/dany1468/items/2d5e18dee84225ede77d

コントローラ、ルーティングの追加

先ほどのサービスクラスを使ってチャットの返事を返すAPIを設計します。エンドポイントはPOST /chatとし、messageというパラメータを受け付けます。

リソース(DB)の変更が発生しないのにPOSTリクエストを使った理由は、POSTリクエストであればリクエストボディに含められるデータ量に制限がないので、ユーザーが長いメッセージを送る可能性のある今回のようなケースに適していると判断したからです。

config/routes.rbにルーティングを追加し、app/controllers/chat_controller.rbを作成します。

config/routes.rb
Rails.application.routes.draw do
  post '/chat', to: 'chat#create'
end
app/controllers/chat_controller.rb
class ChatController < ApplicationController
  def create
    response = Openai::ChatResponseService.new.call(params[:message])
    render json: { response: response }
  rescue => e
    render json: { error: e.message }, status: :internal_server_error
  end
end

Openai::ChatResponseServiceクラスの呼び出しで発生した例外は、コントローラ内で捕捉し、500のステータスコードとともにエラーメッセージをレスポンスで返しています。

実行

準備が整ったので、設計したAPIにリクエストを投げます。

$ curl -X POST http://localhost:3000/chat \
-H "Content-Type: application/json" \
-d '{"message": "ChatGPTの技術記事を書いてるよ!"}'

{"response":"すごいですね!どのような技術記事を書いているのでしょうか?具体的なテーマや内容について教えていただけますか?"}

返事がきました! 自分で設計したChatGPT botなので、かわいらしさもひとしおです。

続いて、環境変数のAPIキーを適当な値に変更し、あえてエラーを発生させてみます。

$ curl -X POST http://localhost:3000/chat \
-H "Content-Type: application/json" \
-d '{"message": "認証テスト"}'
{"error":"Incorrect API key provided: aaa. You can find your API key at https://platform.openai.com/account/api-keys."}

エラー処理もうまく行っているようです。もうひとつ、タイムアウト時間を極端に短くしてテストしてみます。

app/controllers/chat_controller.rb
class ChatController < ApplicationController
  def create
    response = Openai::ChatResponseService.new(timeout: 1).call(params[:message])
    ...
  end
end
$ curl -X POST http://localhost:3000/chat \
-H "Content-Type: application/json" \
-d '{"message": "タイムアウトテスト"}'

{"error":"リクエストがタイムアウトしました。もう一度お試しください。"}

こちらもいい感じです。

まとめ

というわけで、RailsアプリにChatGPT(OpenAI API)を組み込むことができました。

RubyではOpenAIの公式ライブラリが準備されていない分、Faradayを使ったHTTPリクエストやタイムアウト処理を学ぶのによい題材になりました。

GitHubで編集を提案

Discussion