RailsのAPIでリクエストに冪等性を持たせられるように実装してみた

この記事は[ギルドワークスのアドベントカレンダー](https://adventar.org/calendars/4265)の17日目です。


業務で冪等なAPIの実装を行う場面があったので、そのとき調べたものをまとめてみます。

## 冪等とは（冪等性と安全性）
[Webを支える技術（7章：HTTPメソッド）より](https://www.amazon.co.jp/dp/4774142042/ref=cm_sw_em_r_mt_dp_U_Dgd9DbWPZG8JK)

冪等：ある操作を何回行っても結果が同じになること
安全性：操作対象のリソース状態に変更を加えないこと

|  |安全性あり  |安全性なし  |
|:---:|:---:|:---:|
|冪等  |GET、HEAD  |PUT、DELETE  |
|冪等ではない  |  |POST,PATCH  |

## 冪等なAPI
[Stripeのブログ記事](https://stripe.com/jp/blog/idempotency)を読んで、冪等なAPIの設計について調べてみました。

### すべてのAPIのリクエストは失敗する可能性がある
（クライアントとサーバーによる）分散システムでは、すべてのAPIは失敗する可能性があり、まず考えられるのは以下のような理由です。
> ・The initial connection could fail as the client tries to connect to a server.
> ・The call could fail midway while the server is fulfilling the operation, leaving the work in limbo.
> ・The call could succeed, but the connection break before the server can tell its client about it.

- (突発的なサーバー負荷の増大などにより処理能力が追いつかず、）コネクションの初期化に失敗し、サーバーとの接続に失敗したとき
- サーバー側でリクエストが処理の途中で失敗し、中途半端な状態で処理を終わらせたとき
- APIの呼び出しには成功したが、サーバーからのレスポンスを受け取る前にコネクションが切れてしまったとき(ネットワークの遅延によるタイムアウト)

サーバーからのレスポンスで失敗したことが明確であれば、リトライを安全に行うことが可能ですが、
上記のようなケースでAPIリクエストに失敗した場合、クライアントは曖昧な状態で取り残されてしまいます。
このとき、APIが冪等であるならば、レスポンスの結果に関係なく、クライアントからリトライ処理を実行して状態を一意に決めることが可能になります。

### 冪等なAPIが必要になる場面
- 外部のサービスを利用してクレジットカード決済を行うとき、2重決済を防ぎたいとき
- リクエストを受け取ってjobを登録し、非同期で処理を実行するとき、重複したjobの登録を防ぎたいとき
- 実行時間の長い処理を実行するとき、処理が完了する前に同じリクエスト登録されることを防ぎたいとき
- API呼び出し側でリトライ処理を安全に行いたいとき

### クライアント側でのリトライ処理
エラーをハンドリングして、リトライ処理を実施する場合、リトライ処理に加えて、エクスポネンシャルバックオフアルゴリズムを実装すると、サーバーへの負荷を減らすことができます。
エクスポネンシャルバックオフの説明は、GCPのドキュメントに良い解説が載っていました。
https://cloud.google.com/iot/docs/how-tos/exponential-backoff

一定感覚で処理を再試行すると、リクエストを受け取るサーバーに大きな負荷がかかる可能性があるので、クライアントが通信に失敗した際にリクエスト間の遅延を増やしながら定期的に再試行するアプローチです。

以下のロジックの実装方法は[StripeのRubyライブラリ](https://github.com/stripe/stripe-ruby/blob/1bb9ac48b916b1c60591795cdb7ba6d18495e82d/lib/stripe/stripe_client.rb#L78-L92)を参照したものです。

```ruby
def self.sleep_time(retry_count)
  # Apply exponential backoff with initial_network_retry_delay on the
  # number of attempts so far as inputs. Do not allow the number to exceed
  # max_network_retry_delay.
  sleep_seconds = [Stripe.initial_network_retry_delay * (2 ** (retry_count - 1)),   Stripe.max_network_retry_delay].min
  
  # Apply some jitter by randomizing the value in the range of (sleep_seconds
  # / 2) to (sleep_seconds).
  sleep_seconds = sleep_seconds * (0.5 * (1 + rand()))
  
  # But never sleep less than the base sleep seconds.
  sleep_seconds = [Stripe.initial_network_retry_delay, sleep_seconds].max
  
  sleep_seconds
end
```

StripeのRubyライブラリでは、エクスポネンシャルバックオフに加えて、「jitter」というランダムな遅延時間を付与して、Thundering Herd問題を回避しています。

### 冪等なAPIの要件
[Stripeのブログを参照: Codifying the design of robust APIs](https://stripe.com/jp/blog/idempotency)

- エラーが発生したとき、状態の一貫性を持たせてください
  - エラーのとき、クライアントに処理を再試行させて、クライアント側でも状態の一貫性を持たせて、問題が起きるのを回避する

- エラーが発生したとき、安全に確認できるようにしてください

  - ユニークな冪等性キーを使ってリクエストを行い、再試行する場合には冪等性キーを使用して冪等なリクエストを可能にする

- エラーが発生したとき、節度を持って対応を行うようにしてください

  - エクスポネンシャルバックオフを使って、サーバーへの負荷を下げる


## RailsでのAPI実装

### 処理のフロー図
![sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60000/dc084836-1c4e-6078-d3da-445f4cbf98db.png)


### キャッシュしたレスポンスを永続化するためのストレージの選択と設定

今回はシンプルにPostgreSQLのデータベースをストレージに使用して実現してみます。
※試しては無いですが、Redisを使用すると、冪等性キーの有効期限の管理が簡単になりそうです。

今回はキャッシュするレスポンスの内容として、以下の3点にしています。

- idempotency_key
- body
- headers

```ruby:db/migrate/20191215002826_create_idempotency_actions.rb
class CreateIdempotencyActions < ActiveRecord::Migration[6.0]
  def change
    create_table :idempotency_actions do |t|
      t.string :idempotency_key, null: false, index: { unique: true }
      t.integer :status
      t.text :body
      t.text :headers

      t.timestamps
    end
  end
end
```

有効期限は24時間に設定しています。
有効期限が切れたものは、定期バッチで削除するようにするとより良いと思います。

```ruby:app/models/idempotency_action.rb
class IdempotencyAction < ApplicationRecord
  USABLE_PERIOD = 1.days
  scope :usable, ->() { where("created_at >= ?", Time.zone.now - USABLE_PERIOD) }
end
```

クライアントからリクエストを送信するとき、一緒に `Idempotency-Key` の存在をチェックして、HTTPリクエストのHeaderに含まれている場合はキャッシュされたレスポンスから値を返すようにします。
また、HTTPリクエストのHeaderに含まれてい無か、DBに一致する有効なキーが存在しない場合は、新規のリクエストとして扱うようにします。

`IdempotencyAction` を新規に作成するときには、リクエストを受け取ったが処理はされていないという意味で、レスポンス用のステータスとして、accepted(202)を設定します。

```ruby:lib/idempotency_request/database.rb
module IdempotencyRequest
  class Database
    IDEMPOTENCY_HEADER = 'HTTP_IDEMPOTENCY_KEY'.freeze

    def initialize app
      @app = app
    end

    def call env
      dup._call(env)
    end

    def _call(env)
      idempotency_key = env[IDEMPOTENCY_HEADER]
      puts env

      if idempotency_key.present?
        action = ::IdempotencyAction.usable.find_by(idempotency_key: idempotency_key)

        if action.present?
          status = action.status
          headers = action.headers.present? ? ActiveSupport::JSON.decode(action.headers) : {}
          response = action.body.present? ? [ActiveSupport::JSON.decode(action.body)] : []
        else
          idempotent_action = ::IdempotencyAction.create!(
            idempotency_key: idempotency_key,
            status: 202
          )
          status, headers, response = @app.call(env)
          body = response.respond_to?(:body) ? response.body : nil

          idempotent_action.update(
            status: status,
            body: body.to_json,
            headers: headers.to_json
          )
        end

        [status, headers, response]
      else
        @app.call(env)
      end
    end
  end
end
```

上記で作成したmiddlewareは、リクエストが送られてくるたびに実行されるように実装しました。
もともと安全な、getなどのリクエストについて、実行する必要がない場合は、分岐を入れて使用していきます。
middlewareとして実装することで、既存のAPIに影響を加えること無く、APIを利用するクライアント側の判断で利用をするかどうかを任意に選択することができるようになります。
また、冪等性キーを永続化するストレージをDB, Redis, Yamlファイルなど用途に合わせた切り替えが可能になります。

```ruby:config/application.rb
require_relative 'boot'

require 'rails/all'
Dir['./lib/idempotency_request/*.rb'].each { |f| require f }
Bundler.require(*Rails.groups)
module Myapp
  class Application < Rails::Application
    config.load_defaults 6.0
    config.middleware.use IdempotencyRequest::Database
  end
end
```

### 確認用にルーティングを設定

今回は冪等性（何回実行しても同じ結果になること）を確認するために `/verify_idempotency` へアクセスしたときに、ランダムなUUIDがレスポンスとして返ってくるだけのAPIを考えます。

```ruby:app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery unless: -> { request.format.json? }

  def verify_idempotency
    render json: { result: SecureRandom.uuid }
  end
end
```

```ruby:config/routes.rb
Rails.application.routes.draw do
  post 'verify_idempotency', to: 'application#verify_idempotency'
end
```

上記のソースコードは以下に載せています。
https://github.com/t-koshi/rails-idempotent-requests

### エンドポイントにPOSTリクエストを送信して結果を確認
作成したエンドポイントに対して、curlでPOSTリクエストを送信してみます。
`idempotency_key` には `UUID(v4)` を使用して、ユニークな文字列を作成します。
同一の `idempotency_key` でリクエストを行ったとき、キャッシュされたレスポンスが返ってくることが確認できます。

```bash
curl --request POST \
  --url http://localhost:3000/verify_idempotency \
  --header 'idempotency_key: bcf7cbfc-2a1a-4b2f-8dcc-8b961693dea2'
```

## まとめ

 - 分散システムでは、すべてのAPIは失敗する可能性がある
 - APIリクエストに失敗した場合、クライアントが曖昧な状態で取り残さるのを防ぐために、APIに冪等性をもたせた
 - 冪等性キーを保存するためのストレージをDBにしました。
 - APIが冪等な場合、何度実行しても同じ結果が得られるため、リクエストに失敗した場合、リトライ処理を行うことで、リソースの状態を確認できるようにした
 - 冪等性の利用はクライアント側で使用するかどうか選択できるようにした

### 参考
- https://stripe.com/ja-us/blog/idempotency
- https://ieftimov.com/post/understand-how-why-add-idempotent-requests-api/