同じキーでも同じリクエストとは限らない:設計原理
一言でいうと
キーの範囲・本文の正規化・再利用の衝突を分けて、再送を判定します。
なぜ必要なのか
2人の顧客がたまたま同じ冪等キーを使ったところ、ある顧客に別の顧客の応答が返されました。別のリクエストでは、同じキーで金額を変えたのに、以前の成功を返しました。冪等キーそのものだけを比べると、リクエストの意味とセキュリティ境界を見落とします。キーはテナントと作業の範囲に結び付け、本文の意味は、別途フィンガープリントで検査する必要があります。
どう動くのか
文字列キーの文法を検証し、HTTPメソッドと正確なパスをテナントと結び付けます。JSONのキーの順序と空白は正規化しますが、配列の順序は保持します。NaNは標準のJSONの値ではないので拒否します。リクエストのフィンガープリントはSHA-256で計算し、同じ範囲のキーに別のフィンガープリントが入ってきたら、衝突として処理します。保存された応答はディープコピーで返して、呼び出し側が以降の再送の結果を変えられないようにします。
tenant + method + path + key → 범위 키
본문 → 정규 JSON → 지문 → 최초 저장 / 같은 요청 재생 / 다른 요청 충돌
契約を読んで失敗を予測するワークシート
以下は、実装を丸ごと暗記するための解答ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を壊しています。変更後も、正常なケースは通ることがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが現れるかを予想し、実装したあとで、その予想と結果を比べます。
1. キーの文法を検証する
valid_key(value)は、英字・数字・アンダースコア・ハイフンの1–64文字だけをそのまま返し、ほかの入力はValueErrorです。
判断の根拠: キーの長さと許可する文字を制限し、空のキーを、正常な再送として扱いません。
レビューする誤った変更の断片:
{1,128}
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
2. オブジェクトの順序は畳み、配列の順序は保持する
canonical(body)は、dictだけを受け取り、sort_keys=True、separators=(',',':')、ensure_ascii=False、allow_nan=FalseのJSON文字列で返します。シリアライズできない値は、ValueErrorに統一します。
判断の根拠: 配列をソートすると、ユーザーがリクエストした作業の順序を変えてしまいます。
レビューする誤った変更の断片:
sort_keys=False
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
3. 本文のフィンガープリントを計算する
fingerprint(body)は、canonical(body)のUTF-8バイトにSHA-256を適用した、64文字のhex文字列です。
判断の根拠: Pythonのhash()はプロセスごとに変わるので、保存するフィンガープリントとしては使いません。
レビューする誤った変更の断片:
hashlib.sha512(
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
4. テナントと作業でキーを区別する
scoped_key(tenant, method, path, key)は、tenantとkeyをvalid_keyで検証し、methodを大文字に変えます。pathは/で始まる文字列である必要があります。4つの値をJSON配列として、separators=(',',':')でエンコードして返します。
判断の根拠: 単純な区切り文字での連結よりも、構造をエンコードするほうが、境界が明確です。パスの大文字小文字は保持します。
レビューする誤った変更の断片:
method.upper(), path.lower(),
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
5. 3つの判定を区別する
classify(record, digest)は、record=Noneなら'new'、record['fingerprint']==digestなら'replay'、それ以外は'conflict'です。
判断の根拠: キーが存在するという理由だけで、すべての再リクエストを成功として再生しません。
レビューする誤った変更の断片:
else "replay"
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
6. 応答を保存するときにコピーする
remember(records, key, digest, response)は、新しいキーに{fingerprint:digest, response:responseのdeepcopy}を保存します。すでにあればValueErrorで、既存の記録は保持します。
判断の根拠: 応答の中のリストもコピーしなければ、入れ子の状態が共有されます。
レビューする誤った変更の断片:
response
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
7. 応答を読むときもコピーする
replay(record)は、record['response']のディープコピーです。
判断の根拠: 最初の応答を修正した呼び出し側が、次の再送の結果まで変えられないようにします。
レビューする誤った変更の断片:
record["response"]
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
8. 業務関数を1回だけ呼び出す
execute(records, tenant, method, path, key, body, action)は、scopeとフィンガープリントを計算します。newならaction()の結果をrememberしてコピーを返し、replayなら既存の応答のコピーを返し、conflictならValueErrorです。actionの例外は伝播させ、記録を残しません。
判断の根拠: 業務関数の呼び出し回数と、失敗のあとに残った記録まで検査して初めて、再送の契約がわかります。
レビューする誤った変更の断片:
if state in ("new", "replay"):
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
現場での姿
このラボは、単一プロセスのメモリ上のディクショナリで、キーとリクエストの意味を分ける契約を学びます。プロセスの障害後の保持や複数ワーカーの並行性は、次のSQLiteのラボで扱います。ハッシュは暗号化ではなく、JSONの正規化がすべての言語の数値表現まで標準化する国際規格だとは主張しません。
次のラボですること
8つのステップが、1つの実行可能な成果物につながります。キーの文法を検証する → オブジェクトの順序は畳み、配列の順序は保持する → 本文のフィンガープリントを計算する → テナントと作業でキーを区別する → 3つの判定を区別する → 応答を保存するときにコピーする → 応答を読むときもコピーする → 業務関数を1回だけ呼び出す。
各ステップは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとは、わざと境界の比較や後始末のコードを変えて、どの試験が失敗するかを確認してください。前の試験が次のステップでも維持される理由を説明し、このラボが保証しない運用上の条件を1つ書いてみてください。