この記事は
サーバーサイドから外部APIを利用する場合、HTTP通信処理を書く必要がありますよね。
rubyでHTTP通信の処理を書こうと思いライブラリを検討したら、FaradayがデファクトスタンダードになりつつあるようだということでFaradayで書くことにしました。
rubyには標準ライブラリとしてNet::HTTPがありますが、rubyガチ勢は要件や好みに合わせて他のHTTP通信ライブラリ使い分けたりするらしいです。
https://blog.bearer.sh/top-ruby-http-client-gems/
Faradayはこうした各種ライブラリを共通コードで動かすラッパーとして作られたそうなのですが、
- 普通の使い方ならNet::HTTPで事足りる
- Net::HTTPは書き方が今風じゃなくてちょっと使いにくい
ということでFaradayの採用例が増えるのは納得です。
APIへのアクセスは最低限
- ヘッダを指定する
- POSTやPATCHの場合、ボディを指定する
- urlを指定する
- メソッドを指定する
- リクエストを実行する
- レスポンスからボディを取り出してパースする
の6ステップをコードすればOKですが、実運用では以下3点がポイントになってくるかと思います。
- 想定外のレスポンスで後続処理がバグらないこと
- 一時的高負荷が原因の場合、リトライを行なって完了できること
- 最終的に失敗した場合、後から調査できること
3点挙げましたが、全部エラーハンドリングに関係するところですね。
HTTP通信は案外とエラーの種類が多くなる処理です。今回はちょっぴり疑り深い人がざっくりエラーハンドリングをFaradayの機能で実装していきます。
まず、どんなエラーが起こるのか
まずHTTP通信の流れを確認すると大体こんなイメージです。
- 外部通信のためのTCPソケットを作成
- TCPソケットから外部APIサーバー(のTCPソケット)へ接続確立
- HTTPリクエスト抽送
- 外部APIサーバーでリクエスト内容を評価、レスポンス作成
- HTTPレスポンスを受信
では、ここからそれぞれの段階でどんなエラーが起きるのか考えてみます。
「外部通信のためのTCPソケットを作成」時のエラー
ファイルシステムの書き込みエラーとかでしょうか。多分SocketなんちゃらとかPermissionなんちゃらとかOSなんちゃらみたいなエラーが出ると思いますが、これは多分開発時に気がつくと思います。ということでパス。
「TCPソケットから外部APIサーバーへ接続を確立」時のエラー
まず最初にDNSでAPIサーバーのIPを探す必要がありますが
- 内部ネットワーク上のAPIの場合、DNSで見つからない
- インターネット上のAPIの場合、外までの経路が見つからずグローバルDNSが探せない
といったトラブルがあります。あるいはプロキシを使ってインターネットに出る場合
- プロキシのIPがDNSで見つからない
- プロキシの認証に弾かれる
ことも起こります。
rubyの場合、通信自体の処理はC言語依存のライブラリで処理しているようなのでシステムコールでエラーが発生し、ruby側では"Errno:XXXXX"のような形式でraiseされるか、接続経路が見つからないとかプロキシの認証エラーとか、あとは単純に接続失敗やタイムアウトのエラーとして上がってくる予感がします。ソケットエラーとかもあるかもしれません。
「HTTPリクエスト抽送」時のエラー
1つはSSLのエラーがありそうです。rubyって確かopensslとかrubyのビルド時に取り込んでいたと思うので、環境を変更した時とかに結構SSL周りのエラー見ますよね。
この辺はSSLエラーとか、もし専用のエラークラスがなければ接続失敗やタイムアウトになりそうです。
あとはクライアントの実装によっては特定のヘッダをブロックするとかがあるかもしれませんね(確か、perlのNet::SMTPなんかはメールのfromに適当を書くと接続失敗を返す気が。ややこしい。)
Faradayは特になさそうな気がしますが。
「外部APIサーバーでリクエスト内容を評価、レスポンス作成」時のエラー
このステップは時間がかかるので、この間にあちこちで起こるタイムアウトに注意です。
クライアント側のライブラリがタイムアウトと判断した場合はErrno:ETIMEDOUTとかクライアント実装のtimeout系でしょう。一方タイムアウトがゲートウェイで発生した場合は例外がraiseされずに、HTTPステータスコードが502や504が返ってくる事が多いです。
また、基本的にはAPI実装側の問題ですが、APIがさらに他のAPIやデータベースに接続してタイムアウトとするとAPIの処理中で例外になり、これがハンドリングされていないとHTTPステータスコード500が返る可能性もあります。
また、外部APIに不具合があって失敗した場合はHTTPステータス500、リクエストがAPI仕様に合わない場合は400(Bad Request), 401(Not Authorized), 403(Forbidden), 404(Not found)あたりを返してくるはずです。
「HTTPレスポンス受信」時
基本的にこの段階ではステータスコード200番台が返ってきているはずで、エラーが起こるとすればHTTPクライアントから受け取った自身のコードで戻り値を評価するまでエラーにはならないはずです。
API側を開発中にJSONの形式をミスっていて、フロント側の人から「パース失敗するんですけど何スか?」と言われたことはあります。すみません。
各言語のJSONとオブジェクトを変換するライブラリをデフォルト設定で使っていれば大体バグらないはずですよね、あれってなんで起きたんだっけな。。。
タイムアウトの発生ポイント
先ほどタイムアウトについて「あちこちで起こる」と書きましたが、実際タイムアウトの発生箇所は多いです。これは各々のシステムやライブラリが自身の処理のハング防止にタイムアウトを設定しているためです。(古いライブラリとか低層だとタイムアウト機能ががなくて乙ったりするみたいですが)
タイムアウトの発生箇所をまとめてみます。
クライアント側
- TCPソケット作成〜ソケット削除 →ソケットタイムアウト
- 接続処理開始orTCPソケット作成〜接続確立 →接続タイムアウト
- 接続確立orリクエスト送信〜レスポンス受信 →リードタイムアウト
外部API側
- ゲートウェイサーバ: リクエスト受信〜レスポンス送信 →ゲートウェイタイムアウト
- APIサーバー: リクエスト受信〜レスポンス送信
クライアント側は設定変更で解決可能なので、見分けられるとメンテしやすいですね。
低層ではクライアント側のエラーは例外、外部API側のエラーはHTTPステータスコードと見分けがつきますが、Faradayがこれらをどう処理するのかは後で見てみましょう。
Faradayで実装
Faradayは実際にHTTP通信を行う別のライブラリをadapterと呼んでいます。標準ではNet::HTTPを利用しています。1
Faradayは通信処理中の例外とHTTPレスポンスを一旦まとめた上でadapterの実装とオプションから条件分岐させる設計になっており、処理が集約されたところにretry middlewareの処理を差し込むことで。リトライ処理が実装できるようになっているようです。
さて、通信の結果によって受信側が考えることは
- 成功なら、後続処理にレスポンスボディを渡したい
- リトライして次は成功する失敗なら、リトライしたい
- 完全に失敗なら、ログを残して後続がバグらないように完了したい
retry middlewareを利用してこの3パターンに全ての事象を分岐させるとための実装を考えます。
設計の結論
出来上がった雛形コードが以下のものです。
## APIに定義がないステータスが戻った場合のカスタム例外クラス ##
class HttpStatusCodeException < StandardError
end
def url
'https://example.com/path/to/api' ## APIのURL
end
def api_data_format
'Application/json' ## JSONの場合 ##
end
def api_defined_status
[200, 400, 500] ## API仕様にある全ステータス ##
end
## ここまでは好きな書き方で
## 以下がFaradayを使ったHTTP通信実装に関わる部分
def common_retry_options
{
max: 10, interval: 0.5, interval_randomness: 0, backoff_factor: 0.25,
exceptions: [
Errno::ETIMEDOUT, 'Timeout::Error',Faraday::TimeoutError,
Faraday::ConnectionFailed, Faraday::ClientError, Faraday::RetriableResponse
]
retry_statuses: api_defined_status,
methods: []
}
end
def raise_unless_expected_status response
unless api_defined_status.include? response.status
logger.error(## エラーメッセージ ##)
raise HttpStatusCodeException "Unexpected HTTP status: #{response.status}"
end
end
def get
connection = Faraday.new url do |conn|
conn.headers = {
Authorization: ENV['api_access_token'] ## APIのアクセストークンなど ##,
Accept: api_data_format
}
conn.params = {
key: value,
}
retry_options = common_retry_options
retry_options[:retry_if] = ->(_env, _exc) {
if ## リトライ対象にしたい条件 ##
logger.warn(## 個別エラーメッセージ ##)
true
elsif ## リトライ対象にしたい条件 ##
logger.warn(## 個別エラーメッセージ ##)
true
## 中略 ##
else
false
end
}
retry_options[:retry_block] = -> (_env, options, retries, _exc) {
logger.warn(## 共通エラーメッセージ ##)
}
conn.request(:retry, retry_options)
end
response = connection.get
raise_unless_expected_status(response)
end
def post
connection = Faraday.new url do |conn|
conn.headers = {
Authorization: ENV['api_access_token'] ## APIのアクセストークンなど ##,
"Content-type"=> api_data_format
}
retry_options = common_retry_options
retry_options[:retry_if] = ->(_env, _exc) {
if ## リトライ対象にしたい条件 ##
logger.warn(## 個別エラーメッセージ ##)
true
elsif ## リトライ対象にしたい条件 ##
logger.warn(## 個別エラーメッセージ ##)
true
## 中略 ##
else
false
end
}
retry_options[:retry_block] = -> (_env, options, retries, _exc) {
logger.warn(## 共通エラーメッセージ ##)
}
conn.request(:retry, retry_options)
end
response = connection.post do |request|
request.body = JSON.dump(## 記事オブジェクト ##)
end
raise_unless_expected_status(response)
end
ちなみにFaraday公式のドキュメントにあるRetry MiddlewareのUsageはもっと簡単に、以下のようなコードで書かれています。
retry_options = {
max: 2,
interval: 0.05,
interval_randomness: 0.5,
backoff_factor: 2
}
conn = Faraday.new(...) do |f|
f.request :retry, retry_options
...
end
conn.get('/')
どうしてこれが長ったらしいコードのなったのでしょうか?
なんかオプションに空配列を代入していたり、ちょっとエレガントとは言いにくいですね。
ですがこれが私の行きついた結論です。Faraday関連で調べると人によって導いた最適解が違い、それらも参考にしながら調べて考えたことを以下に書きます。
私の考え方を一言で言えば「全部retry_ifで処理する」です。
なぜretry_ifで処理するためにこのコードなのか?
初期状態での分岐
まずretry.rbのクラス宣言直後に
DEFAULT_EXCEPTIONSという定数があって、ここで
- Errno::ETIMEDOUT
- 'Timeout::Error',
- Faraday::TimeoutError
- Faraday::RetriableResponse
という3つのクラスと1つの文字列が設定されています。このうち
- Faraday::RetriableResponse
はcallメソッドの中で
if @options.retry_statuses.include?(resp.status)
raise Faraday::RetriableResponse.new(nil, resp)
end
とあります。
これはつまりretry_statusesにステータスコードを追加してやると、そのステータスコードが飛んできた場合強制的にraiseするためのカスタム項目です。
なのでデフォルトとしてはタイムアウト系の例外のみリトライ対象として評価されていて、その他の例外は外までキャッチされず例外でない場合は成功と同じになります。
全てのレスポンスをリトライ経路に合流させる
retry middlewareのオプションにあるretry_statusesに設定されたステータスコードを持つレスポンスはリトライのルートに入っていきます。これを利用してAPIが仕様上返しうるステータスコードを全て登録してやるとAPIに異常がない限り全てのレスポンスがリトライのルートに入っていきます。
retry_statuses: api_defined_status ## APIが返しうるステータスコード全て ##,
同時に、ここに該当しないものは処理の最後で例外扱いにします。
def raise_unless_expected_status response
unless api_defined_status.include? response.status
logger.error(## エラーメッセージ ##)
raise HttpStatusCodeException "Unexpected HTTP status: #{response.status}"
end
end
考えうる偶発的な例外もリトライ経路に合流させる
デフォルトで設定されている
- Errno::ETIMEDOUT
- 'Timeout::Error',
- Faraday::TimeoutError
以外は本当に偶発的なエラーではなくリトライの価値がないのでしょうか?
まずエラークラスを眺めてみます。error.rb、するとちょっと怪しそうなエラー
- ConnectionFailed
が見つかります。リンクを辿ると各adapterのエラーハンドリングの過程でraiseされているのですが、どれもFaraday::TimeoutErrorと近いところにあります。
正確なことはわかりませんが、実際に動かすとConnectionFailedが偶発的に発生することが少なからずありました。
次に怪しいのは以下のエラーです。
- ClientError
名前が汎用性を持っているだけに怪しさ満点です。アップデートで変更されたらしいですが過去には
https://tech.toreta.in/entry/2019/12/03/172047
という事例もあり、Faradayの開発上このエラーは既存の割り当ての外でなんらか拾ってraiseしたい場合に使っている雰囲気があります。
ちなみにadapterの1つであるEventMachineのコード[em_http.rb] (https://github.com/lostisland/faraday/blob/99afc0ff6fa5b37b3eb2360f8097d81202279d32/lib/faraday/adapter/em_http.rb)でも、エラー内容からTimeoutErrorやConnectionFailedと判断できなかったものはClientErrorとしてraiseされています。
def raise_error(msg)
error_class = Faraday::ClientError
if timeout_message?(msg)
error_class = Faraday::TimeoutError
msg = 'request timed out'
elsif msg == Errno::ECONNREFUSED
error_class = Faraday::ConnectionFailed
msg = 'connection refused'
elsif msg == 'connection closed by server'
error_class = Faraday::ConnectionFailed
end
raise error_class, msg
end
ということで例外の設定を書き換えて、ConnectionFailedとClientErrorを追加します。
exceptions: [
Errno::ETIMEDOUT, 'Timeout::Error',Faraday::TimeoutError,
Faraday::ConnectionFailed, Faraday::ClientError, Faraday::RetriableResponse
]
メソッドでのリトライ判定を無効にする
リトライ経路に入った処理はretry_ifより前にmethodsで評価されます。
retry.rb
rescue @errmatch => e
if retries.positive? && retry_request?(env, e)
retries -= 1
rewind_files(request_body)
@options.retry_block.call(env, @options, retries, e)
if (sleep_amount = calculate_sleep_amount(retries + 1, env))
sleep sleep_amount
retry
end
end
...
end
...
def retry_request?(env, exception)
@options.methods.include?(env[:method]) ||
@options.retry_if.call(env, exception)
end
retry middlewareのオプションにあるmethodsのデフォルト値に一般的なリクエストメソッド全てが登録されているため標準ではretry_ifを飛び越えてリトライされてしまいます。これをmethodsに空配列を代入することで無効化しました。
methods: [],
なぜ全部retry_ifで処理したいのか?
Faradayにはraise_error middlewareがあり、ステータスコード400番台や500番台をClientErrorに変換することができます。また、主要な400番台のエラーには個別の例外クラスが用意されています。
例えばretry middlewareのmethodsオプションを変更せずにraise_error middlewareを使うことで、400~699はリトライなしの失敗、200~399は成功とすることができます。
が、繰り返しですが500番台のエラーは実質タイムアウトであることも多く、例えばAWSのゲートウェイタイムアウトも502です。
また、ステータスコード200が成功というのは規約に過ぎないので「うちのAPIステータスコードは200で返すけど、ボディにstatusっていうキーがあるからそれで判定してね」みたいな実装もできないことはありません。
この辺を柔軟に設定しようとすると、Faradayの提供するオプションでは無理があります。
これがretry_ifに全て流し込む消極的な理由です。
積極的な理由もあります。
retry_ifはブロックを設定できる上に、内部でレスポンスや例外の内容を自由に取り出すことができるので、ログをさくっと書き出すのには便利だからです。
retry_if: ->(_env, _exc) {
if ## リトライ対象にしたい条件 ##
logger.warn(## 個別エラーメッセージ ##)
true
elsif ## リトライ対象にしたい条件 ##
logger.warn(## 個別エラーメッセージ ##)
true
## 中略 ##
else
false
end
},
retry_block: -> (_env, options, retries, _exc) {
logger.warn(## 共通エラーメッセージ ##)
}
リトライ回数を出力したい場合や、共通のメッセージはretry_blockに書けますね。
あとがき
ということでちょっぴり疑り深い人がさくっとFaradayでHTTP通信を実装する方法でした。
あとはretry_ifの中にAPIの仕様に応じて条件分岐を書き込んでいくだけです。
APIがきれいな実装だったら
retry_if: ->(_env, _exc) {
if _exc.is_a? Faraday::ClientError
logger.warn(## 個別エラーメッセージ ##)
false
if !_exc.is_a? Faraday::RetriableResponse
logger.warn(## 個別エラーメッセージ ##)
true
elsif [502, 504].includes _env.status
logger.warn(## 個別エラーメッセージ ##)
true
else
logger.warn(## 個別エラーメッセージ ##)
false
end
}
とかにしておいて、外で最終的に返ってくる例外をキャッチすればいいのかなと思います。
あ、ClientErrorは基本的にはログを見て解析するまでリトライしなくていいと思ってます。そんなに起きないはず。
疑り深くない人とか、めちゃくちゃ疑り深い人とか、あとコードの行数多いのが嫌な人には適さないかもしれません。
そもそもおかしいところがある、代案があるなどのコメントは随時お待ちしております。
-
と、思ったんですがgithubの最新コードはNet::HTTP::persistantになっていてNet::HTTP自体は直接使わないようにしている様子です。これはNet::HTTPに高速な通信方式を指定するためのラッパーとのこと。 ↩