この記事はギルドワークスのアドベントカレンダーの17日目です。
業務で冪等なAPIの実装を行う場面があったので、そのとき調べたものをまとめてみます。
冪等とは(冪等性と安全性)
冪等:ある操作を何回行っても結果が同じになること
安全性:操作対象のリソース状態に変更を加えないこと
安全性あり | 安全性なし | |
---|---|---|
冪等 | 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実装
処理のフロー図
キャッシュしたレスポンスを永続化するためのストレージの選択と設定
今回はシンプルにPostgreSQLのデータベースをストレージに使用して実現してみます。
※試しては無いですが、Redisを使用すると、冪等性キーの有効期限の管理が簡単になりそうです。
今回はキャッシュするレスポンスの内容として、以下の3点にしています。
- idempotency_key
- body
- headers
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時間に設定しています。
有効期限が切れたものは、定期バッチで削除するようにするとより良いと思います。
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)を設定します。
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ファイルなど用途に合わせた切り替えが可能になります。
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を考えます。
class ApplicationController < ActionController::Base
protect_from_forgery unless: -> { request.format.json? }
def verify_idempotency
render json: { result: SecureRandom.uuid }
end
end
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が冪等な場合、何度実行しても同じ結果が得られるため、リクエストに失敗した場合、リトライ処理を行うことで、リソースの状態を確認できるようにした
- 冪等性の利用はクライアント側で使用するかどうか選択できるようにした