RailsでOpenAI APIを使おう【Faraday / エラーハンドリング】
昨今AI分野ではPythonを使うのが主流になっていますが、今回はRuby on RailsアプリにChatGPTを導入してみました。
特に、実運用で使うことを想定して、エラーハンドリングについて真面目に考察しました。
この記事で紹介しているコードは下記のGitHubレポジトリで公開しています。
もし気になる点がありましたらご指摘いただけるとありがたいです!
環境
- Ruby on Rails 7.1.2
- faraday 2.7.12
使用ライブラリの選定
OpenAI APIの公式ドキュメントはこちら。
RailsからOpenAI APIを利用する一番シンプルな実装方法としては、まずnet/http
などのHTTPクライアント機能を持つ標準ライブラリを使うことが考えられます。
また、同じくHTTPクライアント機能を有するライブラリFaraday
も、拡張性が高く人気です。
さらに最も簡便な手法としては、非公式に開発されている下記のruby-openai
というライブラリを使う手もあります。
コードの簡潔さ、カスタマイズ性、メンテナンス性などを考慮するとそれぞれ一長一短あると思いますが、今回は認証やタイムアウトの機能を追加しやすいfaraday
を使うことにしました。
というのも、実際にOpenAI APIを叩いてみると、500エラーがまあまあの頻度で起こったり、リクエストが複雑化するとレスポンスにものすごく時間がかかったり、日本語でリクエストを送ったのに文字化けしたレスポンスが帰ってくるなど、ChatGPTをコンソールで使用するときと同じような動作の不安定さが気になったからです。
よってOpenAI APIを利用する際は適切にエラーハンドリングできる環境が大事だと感じ、ミドルウェアを使ってロギングやタイムアウトなどの追加機能を簡単に統合できるFaraday
が適していると結論づけました。
ちなみに、先にあげたruby-openai
も内部的にはFaraday
を使っていました!
実装とポイント
ではここから、RailsアプリにChatGPTを組み込んでいきます。作成する機能は、ユーザーがメッセージを送るとChatGPTからレスポンスが帰ってくるというシンプルなものです。なお、RailsアプリはAPIモードで作成しました。
OpenAI APIキーの発行
まず、OpenAI APIの利用に必要なAPIキーをOpenAIのプラットフォームで発行します。発行方法は下記の記事が参考になりました。
faraday
のインストール
次に、Railsにfaraday
をインストールします。環境変数の管理にdotenv-rails
を使うので一緒にインストールします。
gem 'faraday'
gem 'dotenv-rails'
$ bundle install
OPENAI_API_KEY
の設定
環境変数.env
ファイルを作成し、OPENAI_API_KEY
に、先ほど取得したOpenAI APIキーの値を設定します。
OPENAI_API_KEY='your api key'
サービスオブジェクトの作成
OpenAI APIと通信するクラスの設計には、サービスオブジェクトを採用しました。/app
配下に/services/openai/
ディレクトリを作成し、そこに一連の処理をまとめます。
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
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
にミドルウェアを追加するときは、最後にアダプタを必ず指定しなければならないので注意です!
point2 エラーステータスコード
OpenAI APIから返ってくる可能性のあるエラーステータスコードは下記の通り。
特に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
というミドルウェアもあるので、こちらを使ったパターンを検討してもいいかもしれません。
コントローラ、ルーティングの追加
先ほどのサービスクラスを使ってチャットの返事を返すAPIを設計します。エンドポイントはPOST /chat
とし、message
というパラメータを受け付けます。
リソース(DB)の変更が発生しないのにPOSTリクエストを使った理由は、POSTリクエストであればリクエストボディに含められるデータ量に制限がないので、ユーザーが長いメッセージを送る可能性のある今回のようなケースに適していると判断したからです。
config/routes.rb
にルーティングを追加し、app/controllers/chat_controller.rb
を作成します。
Rails.application.routes.draw do
post '/chat', to: 'chat#create'
end
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."}
エラー処理もうまく行っているようです。もうひとつ、タイムアウト時間を極端に短くしてテストしてみます。
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リクエストやタイムアウト処理を学ぶのによい題材になりました。
Discussion