Posted at

Ruby の HTTP Client「Faraday」を使った場合の例外の扱いとリトライ処理をどうするか考えてみた

More than 3 years have passed since last update.

チームメンバー毎でも書き方が違う時があるので、どういうやり方がありそうで、どういう時にそれを適用したら良さそうかを考えたメモです。


Faraday

Ruby 製の HTTP Client です。Ruby Tool Box の HTTP Client カテゴリでは 2 位なので結構使われているライブラリなのではないかと思います。

https://www.ruby-toolbox.com/categories/http_clients

lostisland/faraday - Github

本家の README を見てもらうと分かる通り、いくつかの adapter を選択できるようになっています。

また faraday_middleware を代表とした middleware といった形で拡張可能なのも特徴の一つのようです。

Qiita 内でもたくさん見つかりますね Qiita を faraday で検索


まずエラーもリトライも考慮しない書き方

response を受け取るまでは faraday の REDAME と同じです。

conn = Faraday.new(:url => 'http://sushi.com') do |faraday|

faraday.request :url_encoded
faraday.response :logger
faraday.adapter Faraday.default_adapter
end

response = conn.get '/nigiri/sake.json'


status を参照し成功判定をする

# 例えば 200 のみが成功とする

if response.status == 200
# 成功
else
# 失敗
end

# or

# ステータスによって処理を分ける
case response.status
when 200..299
# 成功
when 400..499
# 400 系は別の処理
when 500..599
# 500 系は別の処理
else
# 他
end


#success? を利用する

200 系をまるっと同じ成功とみなしてよいなら以下で十分です。

if response.success?

# 成功
else
# 失敗
end

参考

さくっと使う程度なら、ここまでのように成功したのかや、ステータスの戻りがどうだったのかで十分だと思います。

一方で、 例えば API Client を作るようなケース だと、いくつか考慮するべきことがでてきます。


  1. API サーバーが一時的に調子が悪い等の場合にはリトライをさせたい

  2. API Client を使う側は結果をステータスコードで分岐をしたくないので、適切な値か例外を返して欲しい

  3. API Client をつかう側は Faraday の例外を処理したくない

以降は、これらについて考えたメモです。

(上記の条件は話を簡単にするためにだいぶ絞ってます

実際はリトライも例外ももっと考えないといけないと思います。)


Faraday が標準で提供している機能

まず、Faraday が標準で提供しているリトライやエラーに関する機能を紹介しておきます。


raise_error middleware

https://github.com/lostisland/faraday/blob/master/lib/faraday/response/raise_error.rb

https://github.com/lostisland/faraday/blob/master/lib/faraday/error.rb

特定のステータスコードで例外を送出する機能を追加する middleware です。

以下のケースで例外を飛ばします。


retry middleware

https://github.com/lostisland/faraday/blob/master/lib/faraday/request/retry.rb

特定の例外や条件において、リクエストをリトライする機能を追加する middleware です。

デフォルトでは以下の例外でリトライを試みます。


  • Errno::ETIMEDOUT

  • Timeout::Error

  • Error::TimeoutError


  • Errno::ETIMEDOUT, Timeout::Error は net/http を adapter に使う場合は Error::TimeoutError に変換される事を期待しているため、その場合は Error::TimeoutError だけをリトライの対象と考えればよいです。

  • デフォルトでは Exponential Backoff でリトライされます。

  • リトライの対象となるのは冪等になる HTTP Method のみです。 参考コード


API Client を作る


目標


  1. リトライは API Client 内で行いたい


    • リトライは Exponential Backoff に。



  2. API Client から送出する例外は API Client の定義した例外としたい


    • Faraday の例外を API Client 外に出さないという意味です



  3. 500系のステータスや、API として定義した 400 系のステータスを例外として受け取りたい


3 に関しては、あくまでインターフェースとして例外と扱いたいケースです。例えば、存在しない事が予期されるようなケースであれば 404 が API リクエストの結果として返ってきても、API Client としては false で返すか nil で返すなどで十分です。

逆に存在しない事が予期されないのであれば、例外として返したいというのが 3 のケースです。



スタディ1: タイムアウトや接続エラー以外にも 500 系のステータスでリトライしたい

まずはリトライに関して考えます。


方法1. raise_error と retry middleware を組み合わせて Faraday にリトライさせる

retry middleware は retry_if オプションがあるものの、例外が発生するのが前提になります。 参考コード

よって、 raise_error で 500 系も ClientError を発生させるようにした上で、 retry_if で 400系を省くというやり方ができます。

conn = Faraday.new(:url => 'http://sushi.com') do |faraday|

faraday.request :retry,
exceptions: [Faraday::Error::TimeoutError, Faraday::Error::ConnectionFailed, Faraday::Error::ClientError],
retry_if: ->(env, _exception) { !(400..499).include?(env.status) }
faraday.response :raise_error
faraday.adapter Faraday.default_adapter
end

raise_error middleware で発生する例外とタイムアウトの例外をリトライの対象としつつ、 retry_if で 400系のステータス以外を対象にしています。


方法2. status のみで分岐して Faraday 以外でリトライさせる

こちらの方が単純に見えますね。リトライには Exponential Backoff を自前で実装しなくていいように Retriable を使います。

# 専用の例外を定義しておく

class ServerError < StandardError; end

Retriable.retriable on: [Faraday::Error::TimeoutError, Faraday::Error::ConnectionFailed, ServerError] do
response = # Faraday でリクエスト
if (500..599).include?(response.status)
raise ServerError, response.body # 今回は body に十分な情報が入っているとする
end

response
end


スタディ2: タイムアウトや接続エラー以外に加えて、 400, 404, 500 系を API Client の例外として受け取りたい

続いて API Client の例外として受け取る部分を考えます。

API Client の名前空間に以下の例外が定義されているものとします。

class BadRequest < StandardError; end # 400 用

class NotFound < StandardError; end # 404 用
class ServerError < StandardError; end # 500 系と接続エラー用
class TimeoutError < StandardError; end # タイムアウト用


方法1. raise_error middleware と自前処理の組み合わせ

conn = Faraday.new(:url => 'http://sushi.com') do |faraday|

faraday.response :raise_error
faraday.adapter Faraday.default_adapter
end

begin
response = conn.(.. # 何らかリクエスト
rescue Faraday::Error::ResourceNotFound => e
raise NotFound, e.message
rescue Faraday::Error::TimeoutError => e
raise Timeout, e.message
rescue Faraday::Error::ConnectionFailed => e
raise ServerError, e.message # 500系と混ざってしまうので、もうちょい情報増やすか例外分けてもいいかも
rescue Faraday::Error::ClientError => e
raise BadRequest, e.response.body if e.response.status == 400

raise ServerError, e.message
end

# 正常処理

raise_error が 400 の例外を投げないので逆に面倒くさい


方法2. status のみで分岐する

conn = # middleware 指定の無い Faraday connection

begin
response = conn.(.. # 何らかリクエスト

case response.status
when 400
raise BadRequest, response.body # body に十分な情報が入っているとする
when 404
raise NotFound, response.body
when 500..599
raise ServerError, response.body
end
rescue Faraday::Error::TimeoutError => e
raise Timeout, e.message
rescue Faraday::Error::ClientError => e # ConnectionFailed でもいいが、親クラスである ClientError で全部拾ってしまう
raise ServerError, e.message # 500系と混ざってしまうので、もうちょい情報増やすか例外分けてもいいかも
end

# 正常処理

方法1 よりはマシだけど、status の分岐と例外処理が入り混じってちょっと見難い。でもまあ、こうなるのかな。


例1, 例2 の両方を実現したい

ポイントになるのは、リトライの対象になるのはタイムアウトや接続エラー、そして 500 系のステータスの場合ですが、400 系はリトライする必要が無いというところです。


方法1. raise_error 無しで Retriable を使う

def request!

Retriable.retriable on: [Faraday::Error::TimeoutError, Faraday::Error::ConnectionFailed, ServerError] do
response = # Faraday でリクエスト
if (500..599).include?(response.status)
raise ServerError, response.body # 今回は body に十分な情報が入っているとする
end

response
end
end

begin
response = request!

case response.status
when 400
raise BadRequest, response.body # body に十分な情報が入っているとする
when 404
raise NotFound, response.body
when 500..599
raise ServerError, response.body
end
rescue Faraday::Error::TimeoutError => e
raise Timeout, e.message
rescue Faraday::Error::ClientError => e
raise ServerError, e.message
end

# or まとめてしまうなら以下でもいけそうです

Retriable.retriable on: [Timeout, ServerError] do
begin
response = conn.(.. # 何らかリクエスト

case response.status
when 400
raise BadRequest, response.body # body に十分な情報が入っているとする
when 404
raise NotFound, response.body
when 500..599
raise ServerError, response.body
end
rescue Faraday::Error::TimeoutError => e
raise Timeout, e.message
rescue Faraday::Error::ClientError => e
raise ServerError, e.message
end

response
end


方法2. raise_error を使い Faraday でリトライ

conn = Faraday.new(:url => 'http://sushi.com') do |faraday|

faraday.request :retry,
exceptions: [Faraday::Error::TimeoutError, Faraday::Error::ConnectionFailed, Faraday::Error::ClientError],
retry_if: ->(env, _exception) { !(400..499).include?(env.status) }
faraday.response :raise_error
faraday.adapter Faraday.default_adapter
end

begin
response = conn.(.. # 何らかリクエスト
rescue Faraday::Error::ResourceNotFound => e
raise NotFound, e.message
rescue Faraday::Error::TimeoutError => e
raise Timeout, e.message
rescue Faraday::Error::ConnectionFailed => e
raise ServerError, e.message
rescue Faraday::Error::ClientError => e
raise BadRequest, e.response.body if e.response.status == 400

raise ServerError, e.message
end

相変わらず 400 の分岐が若干面倒です。


専用の raise_error middleware をいれる

ここまで見てきた方法でも実現できているといえますが、例外処理とステータスコードでの分岐が入り混じっていてパッと見分かりにくいです。

ここで専用の raise_error middleware を入れてみます。

class Sushi::RaiseError < Faraday::Response::Middleware

def on_complete(env)
case env[:status]
when 400
raise BadRequest, message(env)
when 404
raise NotFound, message(env)
when 500..599
raise ServerError, message(env)
end
end

def message(env)
"status: #{env.status} headers: #{env.response_headers} body: #{env.body}"
end
end

Faraday::Response.register_middleware sushi_raise_error: Sushi::RaiseError

conn = Faraday.new(:url => 'http://sushi.com') do |faraday|

faraday.request :retry,
exceptions: [Faraday::Error::TimeoutError, Faraday::Error::ConnectionFailed, Faraday::Error::ClientError, ServerError]
faraday.response :sushi_raise_error
faraday.adapter Faraday.default_adapter
end

begin
response = conn.(.. # 何らかリクエスト
rescue Faraday::Error::TimeoutError => e
raise Timeout, e.message
rescue Faraday::Error::ConnectionFailed, Faraday::Error::ClientError => e
raise ServerError, e.message
end

新たに入れた middleware が専用の例外を投げるので、request 時の例外処理は最小限で抑えられます。

ハンドリングするステータスコードが増えてくるとこちらの方が楽かもしれません。

この方法は、Faraday の Wikiページでも紹介されている以下の Faraday利用ライブラリを参考にしました。

一方で oauth2このように 400, 500 系はraise_errors オプションによって例外とするかエラーを添えて返すかを利用側が選べるようになっています。


所感と注意

今回は API Client 内でのリトライを前提として考えましたが、Client の利用側からすれば「勝手にリトライしてるの?」ともなりかねないので、実施するにしてもきちんとオプションを提供する等しないとまずそうです。

また、例外に関しても今回は話を簡単にするために基本例外を返すようにとしましたが、400 系だけでなく 500 系のステータスも「たまに発生する」と予期されているなら、例外とはせずエラーを生成して返し、利用側での分岐に使ってもらう方ことになると思います。

この辺は特に社内用の API Client を書く場合には利用側が何を例外として扱いたいかを考えつつ書くのもいいかもしれません。

といいつつ、私も例外ってなんだっけと分からなくなってきたので達人プログラマーの例外の部分を読みなおしたりしてました

引用を貼って締めたいと思います。


例外に関する問題の一つに「いつ例外を使用するのか?」というものがあります。これに対する我々の答えは、「例外とは予期せぬ事態に備えるためのものであり、プログラムの通常の流れの一部には組み込むべきではない」というものです。


例外がプログラム中で補足されなかった場合、そのプログラムは停止します。このような前提を置いた上で、「すべての例外ハンドラーを除去しても、このプログラムは動作することができるだろうか?」と自問してください。答えが「ノー」であれば、例外ではない状況下で例外が使われているはずです。


Go はエラーに関しては全然違うアプローチで面白いなぁ。