冪等性(べき等性)を持つAPIの設計例
この記事でわかること
- 冪等性の意味
- 冪等性を持つAPIの処理フローやDBテーブル定義の例(銀行の送金APIを例に)
冪等性とは?
まず初めに冪等性とは何でしょうか?
辞書的な意味でいうと
あることを1回行っても複数回行っても結果が変わらない
ことを冪等性といいます。
例えばお金を引き出すという行動はどうでしょうか?
残高10万円の口座から1万円を引き出してみます。
| 行動 | 結果 |
|---|---|
| 1万円を引き出す | 残高9万円 |
| 1万円を引き出す | 残高8万円 |
| ... | ... |
当然ですが1万円を引き出すたびに残高が減っていきます。
このように同じ行動であっても結果が異なるため
お金を引き出すという行動には冪等性がありません。
では次は扉に鍵を差して右に回すという行動を考えてみましょう。
左に回すとロックされ、右に回すとロック解除となります。
| 行動 | 結果 |
|---|---|
| 右に回す | ロックが解除された状態 |
| 右に回す | ロックが解除された状態 |
| ... | ... |
このように同じ行動を何度行っても結果が同じなので
この場合は冪等性があります。
技術的な例でいえば
INSERT INTO table_name (column1, column2, column3, ...)
VALUES (value1, value2, value3, ...);
は実行するたびに新しいレコードが追加されるので冪等性がありません。
UPDATE Monsters
SET name = 'Godzilla'
WHERE id = 1;
一方で上記のような単純なUPDATE文の場合は
何度実行しても毎回 idが1のレコードのname = "Godzilla" となっているはずなので
冪等性があるといえます。
実際のプログラムを例にすると
open()とclose()は冪等性がありますが、toggleOpen()は冪等性がありません。
export class Locker {
isOpen: boolean = false
constructor(isOpen: boolean = false) {
this.isOpen = isOpen
}
close() {
this.isOpen = false
}
open() {
this.isOpen = true
}
toggleOpen() {
this.isOpen = !this.isOpen
}
}
なぜ冪等性が必要なのか?
決済機能を提供するStripeのAPIドキュメントに冪等性について
以下のように記載がされています。
The API supports idempotency for safely retrying requests without accidentally performing the same operation twice. When creating or updating an object, use an idempotency key. Then, if a connection error occurs, you can safely repeat the request without risk of creating a second object or performing the update twice.
予期せぬ二重呼び出しを避けて安全にリトライができるように、APIは冪等性をサポートしています。オブジェクトを作成したり更新したりするときには idempotency key = 冪等キー を使用してください。そうすれば、もし接続エラーが起きた場合でもオブジェクトを二重に作成したり更新したりすることなく安全に再リクエストすることできます。
idempotency keyとは
後の章で説明しますが
StripeのAPIはリクエストヘッダーに"Idempotency-Key: KG5LxwFBepaKHyUD"というような値を指定することができます。値であるKG5LxwFBepaKHyUDの部分はユニークなIDをセットする必要があり、APIを呼び出す側が責任を持って生成する仕様となっています。このIDがつまりリクエストIDのような役割を果たし、二重呼び出しを避け、安全なリトライを担保してくれることになります。もちろんリトライの際に再度ユニークなIDを生成&セットしてしまうと意味がなくなりますのでご注意を。
curl https://api.stripe.com/v1/customers \
-u sk_test_51SRuEnGtVkztNPUpGyUUxgWeTWzsLL3WOjOLBiYDnnFISqE4rCgFk8X04007uOuZGhCymnMGeESifw74KysDb2fI00rcxQsVkU: \
-H "Idempotency-Key: KG5LxwFBepaKHyUD" \
-d description="My First Test Customer (created for API docs at https://docs.stripe.com/api)"
予期せぬ二重呼び出しを避けて
意図せぬ二重呼び出しによるデータ不整合・重複登録を防ぐため
安全にリトライができるように
エラー中断後、リトライした際に結果が同じになるよう保証するため
この2つが主な理由になります。
口座Aから口座Bへ3000円送金する処理を例にとってみると
予期せぬ二重呼び出しが起きるといつのまにか6000円送金していることになってしまいます。
また...
- 口座Aから3000円引く処理は完了
- 一時的なエラーが発生!
- 口座Bに3000円足す処理は未完了
というような場合、リトライした際に口座Aからもう一度3000円引かれてしまうと大問題です。
すでに完了した処理はスキップして、口座Bに3000足す処理から行われる必要があります。
どうやって冪等性のあるAPIを作るのか?
ざっくりと説明すれば以下のようなやり方となります。
| No | 処理担当 | やること |
|---|---|---|
| 1 | API呼び出し側 | ユニークなKeyを生成しリクエストヘッダーIdempotency-KeyにセットしてAPIを呼び出す |
| 2 | API側 |
Idempotency-KeyをIDとして初回呼び出しのレスポンスなどを管理するレコードをDBに保存する |
| 3 | API側 | 処理状況を詳細を記録するレコードをDBに保存する |
| 4 | API側 | 再リクエストがあった場合、DBに保存された情報をもとに処理を途中から再実行したり、初回呼び出しのレスポンスをそのまま返す |
3と4の部分は排他ロックをかけたり、リトライ時にリクエストボディの検証を行ったり
記載事項以外にも他にもやらないといけない処理がいくつかあります。
以下で細かく見ていきましょう。
1.ユニークなKeyを生成しリクエストヘッダーIdempotency-KeyにセットしてAPIを呼び出す
さきほどのStripeのAPIドキュメントの引用をもう一度読んでみると...
The API supports idempotency for safely retrying requests without accidentally performing the same operation twice. When creating or updating an object,
use an idempotency key. Then, if a connection error occurs, you can safely repeat the request without risk of creating a second object or performing the update twice.
予期せぬ二重呼び出しを避けて安全にリトライができるように、APIは冪等性をサポートしています。オブジェクトを作成したり更新したりするときには
idempotency key = 冪等キー を使用してください。そうすれば、もし接続エラーが起きた場合でもオブジェクトを二重に作成したり更新したりすることなく安全に再リクエストすることできます。
どうやら idempotency key = 冪等キー というものを使うようです。
以下はStripeのAPIリクエストの例です。
curl https://api.stripe.com/v1/customers \
-u sk_test_51SRuEnGtVkztNPUpGyUUxgWeTWzsLL3WOjOLBiYDnnFISqE4rCgFk8X04007uOuZGhCymnMGeESifw74KysDb2fI00rcxQsVkU: \
-H "Idempotency-Key: KG5LxwFBepaKHyUD" \
-d description="My First Test Customer (created for API docs at https://docs.stripe.com/api)"
リクエストヘッダーに-H "Idempotency-Key: KG5LxwFBepaKHyUD"という形で
Idempotency-Key がセットされています。
KG5LxwFBepaKHyUDの部分はAPIを呼び出す側がUUIDv4などユニークな値を生成してセットする必要があります。
API側はヘッダーに含まれるIdempotency-Keyの値を確認して
keyの値が同じであれば同じリクエストとして扱うようです。
もしAPI呼び出し側がリトライする時に同じkeyを使わず再生成した場合には
また別のリクエストとして扱われてしまい意味がなくなってしまいますので気を付けましょう。
ちなみに2025/11/13時点でIdempotency-Keyというヘッダー名はMDN Web Docs上でexperimentalとして扱われています。
つまり明確な標準規格というわけでもないので
リクエストヘッダーのkey名は必ずしもIdempotency-Keyである必要はありません。
PayPalのAPIではPayPal-Request-IdというKey名となっていますし、LINEのMessaingAPIではX-Line-Retry-Keyというkey名が使われていました。
極端なことを言うと、ヘッダーではなくリクエストボディで指定する形でも大丈夫だと思われます。
要はAPIリクエストにユニークなKeyを付与する + リトライ時にはそのKeyを再度指定する
という仕様になれば大丈夫です。
Idempotency-KeyはAPI呼び出し側で生成しないといけない?
本記事ではIdempotency-KeyをAPI呼び出し側で生成する方法を例として挙げています。
しかしIdempotency-Keyをあらかじめ発行するAPIを別で用意しておき
API側でIdempotency-Keyを発行するような仕組みをとる場合もあります。
2.Idempotency-KeyをIDとして初回呼び出しのレスポンスなどを管理するレコードをDBに保存する
API側で受け取ったIdempotency-KeyをDBに保存します。
処理完了時にレスポンス値を保存したりすることで2回目以降のリクエストでも
同じ結果 = 冪等性を保証することができます。
DBのテーブル定義の例は以下になります。
idempotency_keysテーブルのカラム
| カラム名 | 役割 | 備考 |
|---|---|---|
| idempotency_key | プライマリーキー | |
| request_hash | リクエストボディのハッシュ値(SHA256など) | 2回目以降のリクエストでリクエストボディが初回リクエストと同一かどうか判定するため |
| status | 処理ステータス |
IN_PROGRESS SUCCESS FAILED
|
| response_body | 初回リクエストのレスポンス | 値があれば2回目以降のリクエストではこの値を返す |
| response_code | 初回リクエストのステータスコード | 値があれば2回目以降のリクエストではこの値を返す |
| expired_at | 有効期限のタイムスタンプ | idempotency_keyに有効期限を持たせたい場合に必要となりますが、特に有効期限を設定しないのであれば必須ではありません。自前のバッチ処理やDBのTTL機能などで有効期限を過ぎたレコードを削除します。 |
idempotency_keysテーブルの中身の例
| idempotency_key | request_hash | status | response_body | response_code | expired_at | ... |
|---|---|---|---|---|---|---|
| 9e525293-... | 7SDLUH.... | SUCCESS | {"transaction_id":"xxxx-xxxx"} |
200 | 2024/12/10 18:49:07 | ... |
| bb66be72-... | 1DVPAW.... | FAILED | {"message":"fromBankAccountId must be string"} |
401 | 2024/12/10 10:29:33 | ... |
| e1ceb400-... | 0CQHDS.... | IN_PROGRESS | 2024/12/10 20:48:01 | ... |
2回目以降のリクエストではidempotency_keysテーブルに保存された
response_bodyやresponse_codeを使ってそのままレスポンスを返すことができます。
処理が完了したパターンはstatus.SUCCESSとstatus.FAILEDの2パターンがあり
SUCCESSの場合は成功レスポンス、FAILEDの場合はエラーレスポンスがそれぞれレコードに保存される想定です。
注意点としては、どんなエラーも一律でFAILEDにするのではなく
バリデーションエラーなど何度実行しても同じエラーになる場合にFAILEDとして扱います。
DB接続エラーなど一時的に発生したエラーの場合はリトライすることで途中から処理を再実行できます。
なのでIN_PROGRESSのまま、もしくはTEMPORARY_FAILEDなどをFAILEDとは分けて登録しておくのが良いかと思います。
3.処理状況を詳細を記録するレコードをDBに保存する
2の時点でIdempotency-Keyに基づいてレスポンスを保存し、2回目以降に同じレスポンスを返却できる仕組みが整いました。
しかしidempotency_keysテーブルはあくまでIdempotency-Keyを汎用的に管理するテーブルで
リトライの際にどこまで処理をスキップするかなどの判定はできません。
仮に処理が何らかの理由で途中で止まってしまいリトライしようとした際に
処理の進捗状況を細かく管理しておく必要があります。
例えば送金処理の場合、以下のようなbank_account_transaction_recordsテーブルを作成します。
このテーブルのstatusを見ることでより詳細の処理状況が分かり、リトライの際にどこまで処理をスキップするかの根拠にもなります。
| id | idempotency_key | type | status | fromBankAccountId | toBankAccountId | amount | ... |
|---|---|---|---|---|---|---|---|
| xxxx-1 | 9e525293-... | TRANSFER | COMPLETED | id-A | id-B | 3000 | ... |
| xxxx-2 | bb66be72-... | TRANSFER | START_WITHDRAW | id-C | id-D | 5000 | ... |
| xxxx-3 | e1ceb400-... | TRANSFER | START_DEPOSIT | id-E | id-F | 10000 | ... |
4.再リクエストがあった場合、DBに保存された情報をもとに処理を途中から再実行したり、初回呼び出しのレスポンスをそのまま返す
初回のリクエストでは通常のAPIと同じくそのまま処理をして完了しますが
二重呼び出しやリトライなどの場合、Idempotency-Keyを使ってidempotency_keysテーブルからレコードを取得し、状況に応じて途中から処理を再開したり、初回レスポンスをそのまま返したりします。
忘れがちなのが、リクエストの同一性チェックです。
初回リクエストでは
{
"fromBankAccountId": "id-A",
"toBankAccountId": "id-B",
"amount": 3000,
}
2回目のリクエストでは
{
"fromBankAccountId": "id-A",
"toBankAccountId": "id-B",
"amount": 5000,
}
とリクエストボディに差異がある場合は、API側でリクエストボディが初回リクエストと違いますよ~というエラーを返します。
この場合おそらく409 Conflict もしくは422 Unprocessable entityを返すのかなと思いますが
まだ十分に調べられていないので気になる方はご自身で調べていただければと思います。
あとは処理の流れについてですが
初回リクエストが正常終了している場合
一方で何らかの理由で異常終了している場合
同時タイミングでリクエストが競合した場合など
いくつかのパターンがありますが、ざっくりとは以下のような処理フローになるかと思います。
処理フロー図
処理フロー全体

初回リクエスト

2回目以降のリクエスト

--- 初回リクエスト
通常のAPIのように処理を単純に進めていきます。
口座A:3000円引く 口座B:3000円足すなど処理にいくつかのステップがある場合は
transaction_recordsテーブルに処理ステータスを随時更新します。
処理が完了した場合は2回目以降に処理をスキップして同じレスポンスを返せるように
レスポンス情報をidempotency_keysテーブルに保存します。
--- 2回目以降:初回リクエストで正常終了している場合
idempotency_keysテーブルのレコードやtransaction_recordsテーブルのレコードから
初回リクエストと同じレスポンスを返すことができます。
--- 2回目以降:初回リクエストで異常終了している場合
この場合は...
- 完了済みの処理をスキップして途中から処理を再開する
- 処理は行わず初回リクエスト時のエラーレスポンスと同じレスポンスを返す
という2つのパターンがあるかと思います。
なぜすべて1のリトライを行わないのかというと、リクエストボディのバリデーションエラーなど何度処理を再開しても同じエラーになるような場合はエラーレスポンスを保存しておいて、以降はエラーレスポンスをそのまま返すという方法も考えられます。
1の場合はtransaction_recordsテーブルのレコードを取得し
statusの値を見て適宜処理をスキップしつつ処理を再実行します。
2の場合はidempotency_keysテーブルのレコードを取得し
statusがFAILEDならresponse_bodyとresponse_codeの値を使って
レスポンスを返します。
エラーレスポンスも保存して返すべきなのか?
何度実行しても同じエラーが起きるならば単純にリトライしてまた同じエラーレスポンスを生成して返せばよいのでは?
という意見もあるかと思います。
個人的には特に決まった方法があるわけではないので投げやりな意見にはなってしまいますが、設計者次第かなと思います。
--- ほぼ同時のAPI二重呼び出しの場合
idempotency_keysテーブルにレコードを追加するタイミングで
どちらかのリクエストがユニーク制約に引っ掛かりエラーになるかと思います。
もしくは片方が処理中の場合はidempotency_keysテーブルやtransaction_recordsテーブルの該当レコードにロックをかけることで単一のリクエストのみが処理できるようになります。
この場合は409 Conflictを返すのが妥当かと思っていますが
十分に調査したわけではないので気になる方はご自身で調べていただければと思います。
終わりに
今回はAPIの冪等性について色々と調べた上で記事を書いてみました。
冪等性はAPIに限らず、バッチ処理やWebhookなどイベントハンドリング処理でも意識する必要があります。
少し長くなりましたがここまで読んでいただきありがとうございました!
最後に参考となった記事を以下に載せていますので併せて参考にしてください。
参考
Discussion