search
LoginSignup
5

More than 3 years have passed since last update.

posted at

updated at

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

この記事はギルドワークスのアドベントカレンダーの17日目です。

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

冪等とは(冪等性と安全性)

Webを支える技術(7章:HTTPメソッド)より

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

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

冪等なAPI

Stripeのブログ記事を読んで、冪等な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ライブラリを参照したものです。

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

  • エラーが発生したとき、状態の一貫性を持たせてください

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

    • ユニークな冪等性キーを使ってリクエストを行い、再試行する場合には冪等性キーを使用して冪等なリクエストを可能にする
  • エラーが発生したとき、節度を持って対応を行うようにしてください

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

RailsでのAPI実装

処理のフロー図

sample.png

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

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

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

  • idempotency_key
  • body
  • headers
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時間に設定しています。
有効期限が切れたものは、定期バッチで削除するようにするとより良いと思います。

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)を設定します。

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ファイルなど用途に合わせた切り替えが可能になります。

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を考えます。

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
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 でリクエストを行ったとき、キャッシュされたレスポンスが返ってくることが確認できます。

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

まとめ

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

参考

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
5