【実務・中級編】 APIの冪等性(Idempotency)の確保とIdempotency-Keyヘッダー – Web APIアーキテクチャ・データ連携実践ガイド

ネットワークの彼方で「2回押された」ボタン:Idempotency-Keyで実現する、真に安全なREST API設計

インフラの現場を渡り歩いてきた人間なら、誰もが一度は悪夢のようなインシデントに直面したことがあるはずだ。

「ユーザーが決済ボタンを連打したせいで、同じクレジットカードから二重に引き落とされた」
「モバイルアプリのネットワークが一時的に不安定になり、タイムアウトを検知したクライアントが自動リトライした結果、同じ注文データが2件作成された」

HTTPの仕様(RFC 9110)において、GET、PUT、DELETEなどは冪等(Idempotent)であるべきと定められている。つまり、何回同じリクエストを投げようとも、サーバー側の状態は1回実行された場合と変わらないはずなのだ。しかし、リソースの新規作成を行うPOSTや、部分更新を行うPATCHは、本質的に「非冪等(Non-idempotent)」である。これをそのまま実装すれば、ネットワークの揺らぎやユーザーの気の焦りが、そのままビジネス上の致命傷になり得る。

今回は、この厄介な「二重送信の恐怖」をサーバーサイドとクライアントサイドの協調によって見事にいなし、分散システムにおけるデータ整合性を保つための切り札、Idempotency-Key ヘッダーを用いた設計論と実装パターンを、シニアエンジニアの視点から徹底的に紐解いていこう。

—

1. 冪等性(Idempotency)の基本原則とHTTPメソッドの罠

まず、HTTPメソッドのセマンティクスを正しく理解することから始めよう。RFC 9110(HTTP Semantics)では、各メソッドが持つべき性質が厳格に定義されている。

| HTTPメソッド | 冪等性 (Idempotency) | 安全性 (Safety) | 主な用途 |
| :— | :— | :— | :— |
| GET | Yes | Yes | リソースの取得 |
| POST | No | No | リソースの新規作成、処理のトリガー |
| PUT | Yes | No | リソースの完全置き換え(UPSERT) |
| PATCH | 条件付き | No | リソースの部分更新 |
| DELETE | Yes | No | リソースの削除 |

ここで注目すべきは、決済や注文処理の多くが POST で実装されるという点だ。APIクライアント(ブラウザやスマホアプリ)から POST /v1/orders を送信した際、パケットが途中でロスしたり、ロードバランサー(ALBやNginx)とバックエンドの間のコネクションがタイムアウトしたりしたとき、クライアントには「成功したか失敗したか分からない」という状態(Unknown State)が残る。

この恐怖心から、クライアントが「とりあえずリトライしとけ」と再送(Retry)を実行すると、サーバー側で全く同じ注文が二重に生成される。これが、分散システムにおける最も古典的かつ破壊的なバグの一つだ。

—

2. Idempotency-Key ヘッダーのアーキテクチャと通信フロー

この問題をスマートに解決するのが、StripeやAWSなどのモダンなAPIプラットフォームが標準採用している Idempotency-Key カスタムHTTPヘッダーだ。

この仕組みの核心は、「クライアントが一意なキー(UUIDなど)を生成してリクエストに付与し、サーバー側はそのキーと処理結果を一定期間アトミックに保持する」という点にある。

以下のシーケンス図は、正常系と、ネットワーク切断後のリトライ(二重送信)におけるサーバー・クライアント間のやり取りを示したものである。

[Client]                                    [API Gateway / Server]          [Distributed Cache / DB]
   |                                               |                                    |
   |-- 1. POST /v1/charges ----------------------->|                                    |
   |   (Idempotency-Key: uuid-001)                 |-- 2. キー存在チェック (uuid-001) ->|
   |                                               |<-- 3. 未存在(ロック獲得) --------|
   |                                               |                                    |
   |                                               |-- 4. ビジネスロジック実行(決済) ->|
   |                                               |-- 5. 結果とステータスを保存 -------->|
   |<-- 6. 201 Created (処理結果レスポンス) -------|                                    |
   |                                               |                                    |
   |--- (ネットワーク切断・タイムアウト等によりクライアントが同じリクエストを再送) ---|
   |                                               |                                    |
   |-- 7. POST /v1/charges ----------------------->|                                    |
   |   (Idempotency-Key: uuid-001)                 |-- 8. キー存在チェック (uuid-001) ->|
   |                                               |<-- 9. 既存データ・レスポンス返却 --|
   |                                               |                                    |
   |<-- 10. 201 Created (キャッシュされた結果) ----|                                    |

ステップ7で同じ Idempotency-Key を持つリクエストが再送されたとき、サーバーはバックエンドの重い処理(決済処理など)を二度と実行せず、ステップ5で保存しておいた過去のレスポンス(ステータスコードとボディ)をそのまま返す。これにより、完全な冪等性が担保される。

—

3. サーバーサイド実装における設計の急所

この仕組みを自前のAPIサーバーに実装する際、インフラエンジニアとして絶対に押さえておかなければならない「罠」がいくつか存在する。

① アトミックなロックと競合処理(Race Condition)

クライアントが全く同じキーで同時に2つのリクエストを並行して送ってきた場合(ConcurrentToken)、両方のリクエストが「キーが存在しない」と判断して処理を始めてしまうと、二重処理を防げない。
したがって、Redisの SETNX コマンドや、RDBのユニーク制約(Unique Constraint)を利用して、キーの登録とロックの獲得をアトミック(不可分)に行う必要がある。

② 処理中のリクエストに対するハンドリング(Concurrent Request / In-Flight)

1回目のリクエストが現在まさに処理中(In-Flight)である最中に、2回目のリクエストが到達した場合の挙動をどうするか。
一般的には、HTTPステータスコード 409 Conflict を返すか、あるいは1回目の処理が完了するまでリクエストをブロック(ポーリングまたは待機)させる設計が取られる。実務上は、シンプルに 409 Conflict を返し、クライアント側に少し待ってからリトライさせる方がサーバーのリソース枯渇を防ぎやすい。

③ ストレージのTTL(有効期限)管理

Idempotency-Key のメタデータは、永久に保存する必要はない。通常は24時間〜72時間程度のTTL(Time To Live)を設定し、Redisなどのインメモリデータストアに保持するのが定石だ。

—

4. 実装コード例(Python / FastAPI)

ここでは、実務でそのまま流用できる、RedisとFastAPIを用いた Idempotency-Key ミドルウェアの概念実装を示す。

import redis
from fastapi import FastAPI, Header, HTTPException, Request, Response
import json

app = FastAPI()

# Redisクライアントの初期化(本番ではコネクションプール等を適切に設定)
redis_client = redis.Redis(host='localhost', port=6379, db=0)

# 冪等性キーの有効期限(例: 24時間 = 86400秒)
IDEMPOTENCY_TTL = 86400

@app.post("/v1/payments")
async def create_payment(
    request: Request,
    response: Response,
    idempotency_key: str = Header(..., alias="Idempotency-Key")
):
    # 1. キーのRedis上のキー名を定義
    cache_key = f"idempotency:{idempotency_key}"

    # 2. アトミックなロック獲得の試行 (SETNX)
    # 値に "processing" を設定し、キーが既に存在する場合は False が返る
    is_acquired = redis_client.set(cache_key, "processing", ex=IDEMPOTENCY_TTL, nx=True)

    if not is_acquired:
        # すでにキーが存在する場合の状態チェック
        cached_data = redis_client.get(cache_key)
        
        if cached_data == b"processing":
            # 現在進行形で他のリクエストが処理中の場合
            raise HTTPException(
                status_code=409, 
                detail="A request with this Idempotency-Key is currently being processed."
            )
        
        # すでに処理が完了している場合は、キャッシュされたレスポンスを復元して返す
        cached_response = json.loads(cached_data.decode('utf-8'))
        response.status_code = cached_response["status_code"]
        return cached_response["body"]

    try:
        # --- 3. 本来の重いビジネスロジック(決済処理など) ---
        body_json = await request.json()
        
        # (ここに実際の決済処理コードが入る)
        # 仮の成功レスポンスデータ
        response_payload = {
            "status": "success",
            "transaction_id": "txn_123456789",
            "amount": body_json.get("amount")
        }
        status_code = 201
        # --------------------------------------------------

        # 4. 処理結果をシリアライズしてRedisに保存(上書き)
        result_to_cache = {
            "status_code": status_code,
            "body": response_payload
        }
        redis_client.set(cache_key, json.dumps(result_to_cache), ex=IDEMPOTENCY_TTL)

        response.status_code = status_code
        return response_payload

    except Exception as e:
        # 異常系が発生した場合は、キーを削除するか「失敗ステータス」をキャッシュする
        # ※ 実業務の要件に合わせて、失敗をキャッシュするかリトライを許可するかを決定する
        redis_client.delete(cache_key)
        raise HTTPException(status_code=500, detail=str(e))

このコードでは、SET コマンドに nx=True を指定することで、複数プロセス・複数コンテナ環境であっても、最初のリクエストだけが処理の実行権を安全に握れるようになっている。

—

5. クライアント側の実装における鉄則とデバッグTips

サーバー側がいかに完璧でも、クライアント側の実装が雑であれば意味がない。最後に、フロントエンドやAPIクライアントを実装するエンジニアへの実務的なアドバイスをいくつか残しておこう。

クライアント側実装の注意点

1. キーは「リクエスト単位」ではなく「ユーザーの意図(Intent)単位」で生成する
通信エラーでリトライする際は、全く同じ Idempotency-Key(同じUUID)を使い回さなければならない。逆に、ユーザーが入力内容を変更してもう一度「注文ボタン」を押し直した場合は、新しいキーを生成する必要がある。
2. リトライアルゴリズムの併用
ネットワークの瞬断に対しては、ランダムな遅延(Jitter)を伴う「指数バックオフ(Exponential Backoff)リトライ」を組み合わせるのが鉄則だ。

動作確認・デバッグのための curl コマンド

開発やステージング環境で冪等性の挙動を検証する際は、以下のように curl で同じヘッダーを連続して叩いてみるのが手っ取り早い。

# 1回目のリクエスト(新規作成され、201が返るはず)
curl -X POST "https://api.example.com/v1/payments" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: c8112233-4455-6677-8899-aabbccddeeff" \
  -d '{"amount": 1500, "currency": "JPY"}' -i

# 2回目のリクエスト(同じキーで再送。1回目と同じレスポンスが高速に返るはず)
curl -X POST "https://api.example.com/v1/payments" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: c8112233-4455-6677-8899-aabbccddeeff" \
  -d '{"amount": 1500, "currency": "JPY"}' -i

2回目のリクエストに対してサーバーがキャッシュから瞬時に応答し、かつデータベース側で二重レコードが生成されていないことが確認できれば、あなたのAPIはネットワークの荒波を越える堅牢性を手に入れたと言っていい。

—

まとめ

Web APIの設計において、美しいURLや綺麗なJSONスキーマを考えることはもちろん大切だが、「ネットワークは必ず失敗する(Fallacies of distributed computing)」という現実に向き合うことこそが、プロフェッショナルなインフラ・バックエンドエンジニアの仕事だ。

Idempotency-Key は、単なるHTTPヘッダーの一つに過ぎない。しかし、その背後にあるアトミックな状態管理と適切なセマンティクスの理解があれば、クライアントの二重送信やネットワークの気まぐれに怯える必要はなくなる。ぜひ次のAPI設計から取り入れ、信頼性の高いシステム構築を実現してほしい。

コメント

タイトルとURLをコピーしました