- 設計
冪等性とは|APIの冪等キー実装と二重登録を防ぐリトライ設計の一覧
冪等性(べきとうせい)とは、同じ操作を1回行っても何回行っても、結果が同じになる性質です。APIでは、通信が途中で切れて「届いたかどうか分からない」ときに、安心して同じリクエストを送り直せるかどうかを決める性質になります。
登録系のPOSTはそのままでは冪等ではなく、送り直すと同じデータが2件できます。この記事では、それを防ぐ冪等キー(Idempotency-Key ヘッダー)の実装を、PostgreSQLのテーブル定義とPython(FastAPI)の動くコードで示し、同じキーで2回・並行10本・本文違いで送った実行結果を載せます。
確認環境:Ubuntu 24.04、Python 3.11.15、FastAPI 0.142.2、uvicorn 0.46.0、psycopg 3.3.6、httpx 0.28.1、PostgreSQL 16.15、curl 8.5.0、@mermaid-js/mermaid-cli 12.0.0(図の描画確認)。確認日は2026-10-06です。掲載したコードの接続先(DB名 appdb、ユーザー app)とポート番号は、一般的な名前に置き換えています。
この記事の結論
- 冪等キーはクライアントが発行する。1つの操作(例:登録ボタンを1回押す)につき1つ作り、送り直すときは同じキーを使います。
- サーバーは、キーの保存と業務処理を同じトランザクションで行う。途中でエラーになればキーも残らないので、同じキーでやり直せます。
- 同時リクエストは一意制約で1件に絞る。同じキーの並行10本で、作られた予約は1件でした。
- 返し分けは3つ。処理中の再送は409、本文が違う再送は422、完了済みの再送は保存した応答をそのまま返します。
- キーには有効期限を付け、期限を過ぎたものは削除する。
冪等性の早見表
冪等キーつきAPIの返し分け
| 場面 | サーバーの状態 | 返すもの | クライアントの次の動き |
|---|---|---|---|
| ヘッダーが無い | ― | 400 | キーを付けて送る |
| 初回のリクエスト | キーなし | 201(処理結果) | 完了。キーは捨ててよい |
| 完了後に同じキー・同じ本文で再送 | 完了 | 保存した応答(201) | 完了として扱う |
| 完了後に同じキー・違う本文で送信 | 完了 | 422 | 新しい操作なら新しいキーを発行する |
| 処理中に同じキーで再送 | 処理中 | 409 | 少し待って同じキーで再送する |
| 初回の処理がエラーで終了 | キーなし(ロールバック) | 5xx など | 同じキーで再送してよい |
| 有効期限を過ぎたキーで送信 | キーなし扱い | 201(新しい処理) | ― |
HTTPメソッド別の冪等性
| メソッド | 安全(読み取りのみ) | 冪等 | 送り直しの考え方 |
|---|---|---|---|
| GET | はい | はい | そのまま送り直せる |
| HEAD | はい | はい | そのまま送り直せる |
| OPTIONS | はい | はい | そのまま送り直せる |
| PUT | いいえ | はい | 同じ内容で置き換えるだけなので送り直せる |
| DELETE | いいえ | はい | 2回目は「すでに無い」になるが、状態は同じ |
| POST | いいえ | いいえ | 冪等キーを付けて送り直す |
| PATCH | いいえ | いいえ | 「1増やす」のような差分は冪等キーを付ける |
メソッドの性質は RFC 9110(HTTP Semantics)の 9.2.1・9.2.2 にもとづきます。POSTとPATCHが冪等ではないメソッドの例であることは、後述のIETF草案の要旨にも書かれています。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「冪等性とは|APIの冪等キー実装と二重登録を防ぐリトライ設計の一覧」
なぜ二重登録が起きるのか
クライアントから見ると、タイムアウトには次の2通りがあり、区別がつきません。
| 何が起きたか | サーバー側のデータ | そのまま再送すると |
|---|---|---|
| リクエストが届く前に切れた | 登録されていない | 1件できる(期待どおり) |
| 登録は終わったが、応答が戻る途中で切れた | 登録済み | 2件できる(二重登録) |
冪等キーは、この2つをサーバー側で見分けるための目印です。同じキーが来たら「さっきの操作の送り直しだ」と分かるので、もう一度処理せずに前回の結果を返せます。
状態遷移図
1つの冪等キーは、未登録・処理中・完了の3つの状態を移ります。
stateDiagram-v2
[*] --> 未登録
未登録 --> 処理中: 初回のリクエスト(キーを確保)
処理中 --> 完了: コミット(応答を保存)201
処理中 --> 未登録: ロールバック(キーも残らない)
処理中 --> 処理中: 同じキーの再送は409
完了 --> 完了: 同じ本文の再送は保存した応答
完了 --> 完了: 違う本文の再送は422
完了 --> 未登録: 有効期限切れ
「処理中」はテーブルの列ではなく、キーを確保したトランザクションがまだ終わっていない状態を指します。コミットすれば完了、ロールバックすれば未登録に戻るので、「処理中のまま残り続けるキー」が生まれません。
テーブル定義(PostgreSQL)
題材は、社内の備品を予約するAPIです。
-- 業務データ:備品の予約
CREATE TABLE reservations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
item_code text NOT NULL,
quantity integer NOT NULL CHECK (quantity > 0),
reserved_by text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 冪等キーの保存先
CREATE TABLE idempotency_keys (
scope text NOT NULL, -- 例: 'POST /reservations'。利用者IDを含めてもよい
idem_key text NOT NULL, -- クライアントが発行したキー
request_hash text NOT NULL, -- リクエスト本文のSHA-256
response_status integer, -- 保存した応答のステータス
response_body jsonb, -- 保存した応答の本文
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT now() + interval '24 hours',
PRIMARY KEY (scope, idem_key) -- 一意制約。同時リクエストを1件に絞る
);
CREATE INDEX idempotency_keys_expires_at_idx ON idempotency_keys (expires_at);
| 列 | 役割 | 決め方 |
|---|---|---|
| scope | キーが有効な範囲 | エンドポイントごとに分ける。利用者ごとに分けるなら利用者IDも含める |
| idem_key | クライアントが発行したキー | UUIDなど、推測されにくく重複しない値 |
| request_hash | 本文が同じかを確かめる値 | 本文を正規化してからSHA-256 |
| response_status・response_body | 再送時に返す応答 | 初回の応答をそのまま保存 |
| expires_at | 有効期限 | クライアントが再送を続ける最長時間より長くする(例:24時間) |
FastAPIの実装
pip install fastapi uvicorn "psycopg[binary]" httpx
psql "postgresql://app:app@127.0.0.1:5432/appdb" -f schema.sql
"""冪等キーつきの予約登録API(FastAPI + psycopg 3 + PostgreSQL)"""
import hashlib
import json
import os
import time
import psycopg
from fastapi import Body, FastAPI, Header
from fastapi.responses import JSONResponse
from psycopg.types.json import Jsonb
DSN = os.environ.get("DATABASE_URL", "postgresql://app:app@127.0.0.1:5432/appdb")
FAST_409 = os.environ.get("FAST_409", "1") == "1" # 処理中の再送にすぐ409を返すか
WORK_SECONDS = float(os.environ.get("WORK_SECONDS", "0")) # 業務処理にかかる時間の再現用
SCOPE = "POST /reservations"
app = FastAPI()
CLAIM_SQL = """
INSERT INTO idempotency_keys (scope, idem_key, request_hash)
VALUES (%(scope)s, %(key)s, %(hash)s)
ON CONFLICT (scope, idem_key) DO UPDATE
SET request_hash = EXCLUDED.request_hash, response_status = NULL, response_body = NULL,
created_at = now(), expires_at = now() + interval '24 hours'
WHERE idempotency_keys.expires_at < now() -- 期限切れのキーだけ取り直せる
RETURNING idem_key
"""
def problem(status: int, title: str) -> JSONResponse:
return JSONResponse({"title": title, "status": status}, status_code=status,
media_type="application/problem+json")
def fingerprint(payload: dict) -> str:
"""キーの並び順や空白の違いを吸収してからハッシュにする"""
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
@app.post("/reservations")
def create_reservation(payload: dict = Body(...),
idempotency_key: str | None = Header(default=None)):
if not idempotency_key:
return problem(400, "Idempotency-Key ヘッダーが必要です")
params = {"scope": SCOPE, "key": idempotency_key.strip('"'), "hash": fingerprint(payload)}
# with を抜けるときにコミット、例外が出たらロールバックされる(キーの保存と業務処理が同じトランザクション)
with psycopg.connect(DSN) as conn:
cur = conn.cursor()
if FAST_409:
# 同じキーを処理中のトランザクションがあれば、待たずに409を返す
cur.execute("SELECT pg_try_advisory_xact_lock(hashtextextended(%s, 0))",
(f"{SCOPE}:{params['key']}",))
if not cur.fetchone()[0]:
return problem(409, "同じキーのリクエストを処理中です")
# 1. キーを確保する。一意制約により、確保できるのは1リクエストだけ
cur.execute(CLAIM_SQL, params)
if cur.fetchone() is None:
# 2. すでにキーがある:本文が同じなら保存した応答を返し、違えば422
cur.execute("SELECT request_hash, response_status, response_body FROM idempotency_keys"
" WHERE scope = %(scope)s AND idem_key = %(key)s", params)
saved_hash, saved_status, saved_body = cur.fetchone()
if saved_hash != params["hash"]:
return problem(422, "このキーは別の内容のリクエストで使われています")
return JSONResponse(saved_body, status_code=saved_status,
headers={"Idempotent-Replayed": "true"})
# 3. 初回:業務処理を行う
time.sleep(WORK_SECONDS)
cur.execute("INSERT INTO reservations (item_code, quantity, reserved_by)"
" VALUES (%s, %s, %s) RETURNING id",
(payload["item_code"], payload["quantity"], payload["reserved_by"]))
body = {"id": cur.fetchone()[0], **payload}
# 4. 応答をキーと一緒に保存する
cur.execute("UPDATE idempotency_keys SET response_status = 201, response_body = %(body)s"
" WHERE scope = %(scope)s AND idem_key = %(key)s", {**params, "body": Jsonb(body)})
return JSONResponse(body, status_code=201)
処理の流れは4段階です。
| 段階 | やること | ポイント |
|---|---|---|
| 1 | キーを INSERT して確保する | 一意制約があるので、確保できるのは1リクエストだけ |
| 2 | 確保できなければ、保存済みの行を読む | ハッシュが同じなら保存した応答、違えば422 |
| 3 | 業務処理をする | キーの確保と同じトランザクションの中 |
| 4 | 応答をキーの行に保存してコミット | 業務データと応答が同時に確定する |
FAST_409 を有効にすると、段階1の前に pg_try_advisory_xact_lock でキーごとのロックを試み、取れなければすぐ409を返します。このロックはトランザクションの終了とともに自動で外れます。
実行結果:同じキーで2回・本文違い・並行10本
確認用のクライアントです。業務処理に0.5秒かかる想定でサーバーを起動しています。
"""冪等キーの動作確認。python3 client.py http://127.0.0.1:8000"""
import sys
import uuid
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
import httpx
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8000"
BODY = {"item_code": "PJ-001", "quantity": 1, "reserved_by": "taro"}
def post(key=None, body=BODY):
headers = {"Idempotency-Key": f'"{key}"'} if key else {}
r = httpx.post(f"{BASE}/reservations", json=body, headers=headers, timeout=30)
replayed = r.headers.get("Idempotent-Replayed", "-")
return r.status_code, replayed, r.json()
def show(label, result):
print(f"{label}: {result[0]} replayed={result[1]} {result[2]}")
print("== 1. ヘッダーなし")
show(" 1回目", post())
print("== 2. 同じキーで2回")
k = str(uuid.uuid4())
show(" 1回目", post(k))
show(" 2回目", post(k))
print("== 3. 同じキーで本文違い / 同じ本文でキーの並び順だけ違う")
show(" 数量を2に変更", post(k, {**BODY, "quantity": 2}))
show(" 並び順だけ変更", post(k, {"reserved_by": "taro", "quantity": 1, "item_code": "PJ-001"}))
print("== 4. 同じキーで並行10本")
k = str(uuid.uuid4())
with ThreadPoolExecutor(10) as ex:
results = list(ex.map(lambda _: post(k), range(10)))
print(" ステータスと再送フラグの内訳:", dict(Counter((s, rp) for s, rp, _ in results)))
print(" 応答に含まれた予約ID:", sorted({b["id"] for s, _, b in results if s == 201}))
show(" 完了後に再送", post(k))
print("== 5. 毎回キーを変えて並行10本(比較用)")
with ThreadPoolExecutor(10) as ex:
results = list(ex.map(lambda _: post(str(uuid.uuid4())), range(10)))
print(" ステータスの内訳:", dict(Counter(s for s, _, _ in results)))
print(" 作られた予約の数:", len({b["id"] for _, _, b in results}))
WORK_SECONDS=0.5 uvicorn app:app --port 8000
python3 client.py http://127.0.0.1:8000
== 1. ヘッダーなし
1回目: 400 replayed=- {'title': 'Idempotency-Key ヘッダーが必要です', 'status': 400}
== 2. 同じキーで2回
1回目: 201 replayed=- {'id': 1, 'item_code': 'PJ-001', 'quantity': 1, 'reserved_by': 'taro'}
2回目: 201 replayed=true {'id': 1, 'quantity': 1, 'item_code': 'PJ-001', 'reserved_by': 'taro'}
== 3. 同じキーで本文違い / 同じ本文でキーの並び順だけ違う
数量を2に変更: 422 replayed=- {'title': 'このキーは別の内容のリクエストで使われています', 'status': 422}
並び順だけ変更: 201 replayed=true {'id': 1, 'quantity': 1, 'item_code': 'PJ-001', 'reserved_by': 'taro'}
== 4. 同じキーで並行10本
ステータスと再送フラグの内訳: {(409, '-'): 9, (201, '-'): 1}
応答に含まれた予約ID: [2]
完了後に再送: 201 replayed=true {'id': 2, 'quantity': 1, 'item_code': 'PJ-001', 'reserved_by': 'taro'}
== 5. 毎回キーを変えて並行10本(比較用)
ステータスの内訳: {201: 10}
作られた予約の数: 10
読み取れることは次のとおりです。
- 同じキーの2回目は、同じ予約ID(1)が返り、
Idempotent-Replayed: trueが付いた。このヘッダーは、送り直しへの応答だと分かるようにこの記事で付けた目印です。 - 数量だけ変えると422、項目の並び順だけ変えた場合は同じ本文として扱われた。ハッシュの前に項目名で並べ替えているためです。
- 並行10本は、201が1本、409が9本。予約IDは2の1件だけで、完了後の再送には保存した応答が返りました。
- キーを毎回変えると10件できた。送り直しのたびにキーを作り直すと、冪等キーは働きません。
実行後の予約テーブルは12件(項目2で1件、項目4で1件、項目5で10件)でした。
409を返さず、待たせて同じ応答を返す場合
FAST_409=0 で起動すると、ロックの試行を省き、一意制約だけで制御します。
== 4. 同じキーで並行10本
ステータスと再送フラグの内訳: {(201, 'true'): 9, (201, '-'): 1}
応答に含まれた予約ID: [2]
完了後に再送: 201 replayed=true {'id': 2, 'quantity': 1, 'item_code': 'PJ-001', 'reserved_by': 'taro'}
後から来た9本は、最初の1本のコミットを待ってから、保存された応答を受け取りました。PostgreSQLは、一意制約に当たる行を別のトランザクションが挿入中の場合、その終了を待つためです。どちらの設定でも、作られた予約は1件でした。
| 方式 | 処理中の再送への応答 | 向いている場面 |
|---|---|---|
すぐ409(FAST_409=1) |
409をすぐ返す | 処理が長い。接続を待たせたくない |
待って同じ応答(FAST_409=0) |
最初の処理が終わるまで待ち、保存した応答を返す | 処理が短い。クライアントの実装を簡単にしたい |
実行結果:有効期限とロールバック
curlで同じキーを使い、期限切れとロールバックの動きを確かめました(応答ヘッダーは date・server・content-length を省いて掲載しています)。
curl -s -i -X POST http://127.0.0.1:8000/reservations \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: "11111111-2222-4333-8444-555555555555"' \
-d '{"item_code":"PJ-001","quantity":1,"reserved_by":"taro"}'
HTTP/1.1 201 Created
content-type: application/json
{"id":13,"item_code":"PJ-001","quantity":1,"reserved_by":"taro"}
同じコマンドをもう一度流すと、保存した応答が返ります。
HTTP/1.1 201 Created
idempotent-replayed: true
content-type: application/json
{"id":13,"quantity":1,"item_code":"PJ-001","reserved_by":"taro"}
数量を3に変えて同じキーで送ると422です。
HTTP/1.1 422 Unprocessable Entity
content-type: application/problem+json
{"title":"このキーは別の内容のリクエストで使われています","status":422}
次に、このキーの期限をSQLで過去の時刻に変えてから、数量3のリクエストをもう一度送りました。
UPDATE idempotency_keys SET expires_at = now() - interval '1 minute'
WHERE idem_key = '11111111-2222-4333-8444-555555555555';
HTTP/1.1 201 Created
content-type: application/json
{"id":14,"item_code":"PJ-001","quantity":3,"reserved_by":"taro"}
期限切れのキーは新しいリクエストとして処理されました。キーを確保するSQLの ON CONFLICT ... DO UPDATE ... WHERE expires_at < now() が、期限切れの行だけを取り直しているためです。
業務処理がエラーになったとき
数量0は、テーブルのCHECK制約に当たってエラーになります。別のキーで送ると次のようになりました。
HTTP/1.1 500 Internal Server Error
content-type: text/plain; charset=utf-8
Internal Server Error
SELECT count(*) FROM idempotency_keys WHERE idem_key = '99999999-2222-4333-8444-555555555555';
count
-------
0
(1 row)
キーの行は残っていません。業務処理と同じトランザクションだったので、一緒にロールバックされました。続けて同じキーで数量1を送ると、201(予約ID 16)で登録できました。
期限切れのキーを削除する
期限を過ぎた行は、定期的に削除します。
DELETE FROM idempotency_keys WHERE expires_at < now();
DELETE 1
IETFのIdempotency-Keyヘッダー草案と照らす
Idempotency-Key ヘッダーは、IETFのHTTPAPIワーキンググループで草案(draft-ietf-httpapi-idempotency-key-header)として検討されてきました。今回確認した最新版は 07(2025年10月15日付)で、IETF Datatracker上の状態は「Expired」(草案の有効期限 2026年4月18日を過ぎた状態)です。標準として確定した文書ではない点を踏まえて、設計の参考にします。
| 項目 | 草案(07)の記載 | この記事の実装 |
|---|---|---|
| ヘッダーの形式 | Structured FieldのItemで、値はStringでなければならない(二重引用符で囲む) | 二重引用符を外して保存。引用符なしでも受け付ける |
| キーの値 | 一意であること。UUIDなどのランダムな識別子を推奨 | クライアントがUUID(バージョン4)を発行 |
| 本文が違うリクエストでの再利用 | 再利用してはならない | 422を返す |
| フィンガープリント | 本文全体または一部のチェックサムなどを、必要に応じてサーバーが生成 | 項目名で並べ替えたJSONのSHA-256 |
| 有効期限 | サーバー側が方針を定め、文書で公開すべき | 24時間。期限切れは取り直し可、定期削除 |
| ヘッダーが無い(必須のAPI) | 400 Bad Request | 400 |
| 完了後の再送 | 以前に完了した操作の結果を、成功でもエラーでも返す | 保存した201を返す |
| 処理中の再送 | リソースの競合エラーを返す(409 Conflict) | 409(設定により、待って同じ応答) |
| 本文違いでの再利用 | 422 Unprocessable Content | 422 |
クライアント側のリトライ設計の一覧
| 受け取ったもの | 再送するか | キー | 補足 |
|---|---|---|---|
| タイムアウト、接続の切断 | する | 同じキー | 届いたかどうか分からない場面こそ、同じキーで送る |
| 500・502・503・504 | する | 同じキー | 間隔を少しずつ広げて、回数に上限を設ける |
| 409(処理中) | する | 同じキー | 少し待ってから。完了していれば保存した応答が返る |
| 201 などの成功 | しない | 捨てる | 送り直しへの応答でも成功として扱う |
| 422(本文違い) | しない | 新しい操作なら新しいキー | 同じキーに別の内容を載せていないか見直す |
| 400(ヘッダーなし、入力の誤り) | しない | ― | リクエストを直す |
| 401・403・404 | しない | ― | 送り直しても結果は変わらない |
キーを作るタイミングは「HTTPリクエストを送るとき」ではなく「利用者が操作したとき」です。送信の関数の中でキーを作ると、再送のたびに別のキーになり、実行結果の項目5のように送った数だけ登録されます。
冪等キー・一意制約・UPSERT・楽観ロックの違いと選び方
二重の登録や更新を防ぐ方法は、冪等キーだけではありません。
| 方法 | 何を防ぐか | しくみ | 2回目の結果 |
|---|---|---|---|
| 冪等キー | 同じ操作の送り直しによる二重処理 | 操作ごとのキーと応答を保存する | 1回目と同じ応答 |
| 一意制約 | 同じ値を持つ行の重複 | 業務上の一意な項目に制約を付ける | エラー(重複) |
| UPSERT | 「無ければ追加、あれば更新」の重複 | INSERT ... ON CONFLICT DO UPDATE | 同じ1行に上書き |
| 楽観ロック | 他の人の更新を気付かずに上書きすること | 読んだときのバージョンと同じ場合だけ更新 | 更新0件(やり直し) |
PostgreSQL 16で、3つの動きを確かめました。
-- 一意制約:同じ値の2件目はエラーになる
CREATE TABLE members (email text PRIMARY KEY, name text NOT NULL, version integer NOT NULL DEFAULT 1);
INSERT INTO members (email, name) VALUES ('taro@example.com', '山田太郎');
INSERT INTO members (email, name) VALUES ('taro@example.com', '山田太郎');
-- UPSERT:あれば更新、なければ追加。何回流しても1行
INSERT INTO members (email, name) VALUES ('taro@example.com', '山田タロウ')
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO members (email, name) VALUES ('taro@example.com', '山田タロウ')
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name;
-- 楽観ロック:読んだときの version と同じ場合だけ更新する
UPDATE members SET name = '山田太郎', version = version + 1
WHERE email = 'taro@example.com' AND version = 1;
UPDATE members SET name = '山田たろう', version = version + 1
WHERE email = 'taro@example.com' AND version = 1;
SELECT * FROM members;
CREATE TABLE
INSERT 0 1
psql:compare.sql:4: ERROR: duplicate key value violates unique constraint "members_pkey"
DETAIL: Key (email)=(taro@example.com) already exists.
INSERT 0 1
INSERT 0 1
UPDATE 1
UPDATE 0
email | name | version
------------------+----------+---------
taro@example.com | 山田太郎 | 2
(1 row)
どれを使うかの判断基準
| 条件 | 選ぶもの |
|---|---|
| 予約や注文のように、同じ内容を2回登録することが正当にありうる | 冪等キー |
| メールアドレスや注文番号など、業務上の一意な項目がある | 一意制約(冪等キーと併用してよい) |
| 設定の保存のように、最後の内容で上書きできればよい | UPSERT、またはPUT |
| 複数の人が同じデータを編集する | 楽観ロック |
| 「1増やす」「残数を減らす」などの差分の更新 | 冪等キー |
| 外部サービスを呼び出す処理を含む | 冪等キー(呼び出し先にも同じキーを渡す) |
設計で決めておくこと
| 項目 | 決める内容 | この記事の例 |
|---|---|---|
| キーの有効範囲 | エンドポイント単位か、利用者単位か | エンドポイント単位(scope列) |
| 有効期限 | クライアントの再送が続く最長時間より長く | 24時間 |
| 本文の比較 | するか、どの項目を対象にするか | 本文全体を正規化してSHA-256 |
| 処理中の再送 | すぐ409か、待って同じ応答か | 切り替え可能 |
| エラー応答の扱い | 保存して再送にも同じエラーを返すか、キーを残さずやり直せるようにするか | 例外時はロールバックし、やり直せる |
| 削除 | いつ、どう消すか | 期限切れを定期的にDELETE |
| 文書化 | キーの形式、期限、各ステータスの意味 | API仕様書に記載する |
外部サービスの呼び出しのように、データベースのトランザクションに含められない処理がある場合は、「処理中」を列として保存し、先にコミットしてから外部を呼ぶ形になります。その場合は、処理中のまま残った行をいつ解放するかも決めておきます。
確かめて分かったこと
- 一意制約だけでも二重登録は防げる。409は「待たせない」ための追加だった。ロックの試行を省いた設定でも、並行10本で作られた予約は1件でした。後続の9本は、最初の1本の終了を待ってから保存済みの応答を受け取っています。
- 保存した応答は、項目の並び順が変わって返った。初回は
id, item_code, quantity, reserved_byの順、再送ではid, quantity, item_code, reserved_byの順でした。jsonb型は項目の順序を保持しないためです。1文字単位で同じ応答が必要なら、text型で保存します。 - 本文は正規化してからハッシュにしないと、並び順の違いだけで422になる。項目名で並べ替えてからハッシュにしたところ、並び順だけを変えた再送は同じ本文として扱われました。
- ロールバックした処理でも、連番は進む。CHECK制約でエラーになった処理のあと、次の予約IDは15を飛ばして16になりました。IDが連続することを前提にしない設計にします。
- FastAPI(uvicorn)が返す422の理由句は「Unprocessable Entity」だった。草案07では「Unprocessable Content」という名前で書かれています。ステータスコードの数字は同じなので、クライアントは数字で判定します。
- 状態遷移図のMermaidは、遷移のラベルに全角かっこを含めたまま描画できた。
よくある質問
Q. 冪等性とは何ですか? A. 同じ操作を何回行っても、1回行ったときと結果が同じになる性質です。APIでは、同じリクエストを送り直してもデータが重複しないことを指します。
Q. 冪等キーはサーバーとクライアントのどちらが作りますか? A. クライアントが作ります。サーバーが作ると、その応答が届かなかったときにクライアントはキーを知る手段がなく、送り直しであることを伝えられないためです。
Q. 409と422の違いは何ですか? A. 409は「同じキーのリクエストをいま処理中」、422は「同じキーが別の内容で使われた」です。409は少し待って同じキーで再送でき、422は再送しても結果が変わらないので、キーの使い方を見直します。
Q. PUTやDELETEにも冪等キーは必要ですか? A. 基本的には不要です。PUTとDELETEはメソッドの定義として冪等で、同じリクエストを繰り返しても状態は同じになります。冪等キーが役立つのは、POSTや、差分を送るPATCHです。
Q. 冪等キーの有効期限はどのくらいにすればよいですか? A. クライアントが再送を続ける最長の時間より長くします。再送が数分で終わるなら24時間あれば余裕があります。期限と削除の方針は、API仕様書に書いて利用者に伝えます。
まとめ
- 冪等性とは、何回行っても結果が同じになる性質。POSTは冪等キーで冪等にできる。
- 冪等キーはクライアントが操作ごとに発行し、送り直しでは同じキーを使う。
- サーバーは、キーの確保・業務処理・応答の保存を同じトランザクションで行う。
- 一意制約が、同時リクエストを1件に絞る。並行10本で作られた予約は1件だった。
- 処理中は409、本文違いは422、完了済みは保存した応答を返す。
- キーには有効期限を付け、期限切れは削除する。方針はAPI仕様書に書く。
- 業務上の一意な項目があれば一意制約、上書きでよければUPSERT、同時編集には楽観ロックを選ぶ。
参考・出典
- IETF Datatracker「The Idempotency-Key HTTP Header Field」draft-ietf-httpapi-idempotency-key-header-07 https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/ (版・日付・状態、ヘッダーの形式、一意性、有効期限、フィンガープリント、400・409・422の使い分け。2026-10-06閲覧)
- RFC 9110「HTTP Semantics」9.2.1 Safe Methods、9.2.2 Idempotent Methods https://www.rfc-editor.org/rfc/rfc9110.html (2026-10-06閲覧)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「冪等性とは|APIの冪等キー実装と二重登録を防ぐリトライ設計の一覧」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。