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

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

## Faraday

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

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

[lostisland/faraday - Github](https://github.com/lostisland/faraday)

本家の README を見てもらうと分かる通り、いくつかの adapter を選択できるようになっています。
また [faraday_middleware](https://github.com/lostisland/faraday_middleware) を代表とした middleware といった形で拡張可能なのも特徴の一つのようです。

Qiita 内でもたくさん見つかりますね [Qiita を faraday で検索](http://qiita.com/search?utf8=%E2%9C%93&sort=rel&q=faraday)

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

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

```rb
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 を参照し成功判定をする

```rb
# 例えば 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 系をまるっと同じ成功とみなしてよいなら以下で十分です。

```rb
if response.success?
  # 成功
else
  # 失敗
end
```

参考

- https://github.com/lostisland/faraday/blob/e10ef5ea8952b7b848e797e2f80136426063d6b8/lib/faraday/response.rb#L36-L38
- https://github.com/lostisland/faraday/blob/e10ef5ea8952b7b848e797e2f80136426063d6b8/lib/faraday/options.rb#L300-L302
- https://github.com/lostisland/faraday/blob/e10ef5ea8952b7b848e797e2f80136426063d6b8/lib/faraday/options.rb#L259

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

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

1. API サーバーが一時的に調子が悪い等の場合にはリトライをさせたい
2. API Client を使う側は結果をステータスコードで分岐をしたくないので、適切な値か例外を返して欲しい
3. API Client をつかう側は `Faraday` の例外を処理したくない

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

（上記の条件は話を簡単にするためにだいぶ絞ってます :bow: 実際はリトライも例外ももっと考えないといけないと思います。）

## 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 です。

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

- 404: `ResourceNotFound`
- 407: `ConnectionFailed` (Proxy Authentication Required)
- 400...600: `ClientError`
- `ConnectionFailed` は曲者で、407 以外にも各 adapter での接続エラー全般にも使われます。
  - https://github.com/lostisland/faraday/blob/master/lib/faraday/adapter/net_http.rb#L39-L46
- error.rb を見て分かる通り `ClientError` は他の例外の親クラスです。

### 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 のみです。 [参考コード](https://github.com/lostisland/faraday/blob/master/lib/faraday/request/retry.rb#L23)

## 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` オプションがあるものの、例外が発生するのが前提になります。 [参考コード](https://github.com/lostisland/faraday/blob/master/lib/faraday/request/retry.rb#L117-L124)

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

```rb
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](https://github.com/kamui/retriable) を使います。

```rb
# 専用の例外を定義しておく
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 の名前空間に以下の例外が定義されているものとします。

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

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

```rb
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 の例外を投げないので逆に面倒くさい :persevere:

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

```rb
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 を使う

```rb
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 でリトライ

```rb
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 を入れてみます。

```rb
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
```

```rb
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利用ライブラリを参考にしました。

- [facebookarchive/instagram-ruby-gem](https://github.com/facebookarchive/instagram-ruby-gem)
  - https://github.com/facebookarchive/instagram-ruby-gem/blob/master/lib/faraday/raise_http_exception.rb
- [berkshelf/ridley](https://github.com/berkshelf/ridley)
  - https://github.com/berkshelf/ridley/blob/master/lib/ridley/middleware/chef_response.rb
  - https://github.com/berkshelf/ridley/blob/c06cef68ec98244974a3ec772d74556f3a7bcb10/lib/ridley/connection.rb#L45-L67

一方で [oauth2](https://github.com/intridea/oauth2) は [このように](https://github.com/intridea/oauth2/blob/master/lib/oauth2/client.rb#L88-L120) 400, 500 系は`raise_errors` オプションによって例外とするかエラーを添えて返すかを利用側が選べるようになっています。

## 所感と注意

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

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

といいつつ、私も例外ってなんだっけと分からなくなってきたので達人プログラマーの例外の部分を読みなおしたりしてました :sweat: 引用を貼って締めたいと思います。

> 例外に関する問題の一つに「いつ例外を使用するのか？」というものがあります。これに対する我々の答えは、「例外とは予期せぬ事態に備えるためのものであり、プログラムの通常の流れの一部には組み込むべきではない」というものです。  
例外がプログラム中で補足されなかった場合、そのプログラムは停止します。このような前提を置いた上で、「すべての例外ハンドラーを除去しても、このプログラムは動作することができるだろうか？」と自問してください。答えが「ノー」であれば、例外ではない状況下で例外が使われているはずです。

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