WebサービスのAPIの冪等性についての説明を読み、分かったような分からないような気分になったので、自分の理解を整理する。
数学的な意味での冪等性
まず、前提知識。数学的な意味での関数における冪等性は明確。
モノイド \(M\) の元 \(x\) が冪等元であるとは、
$$ x \cdot x = x $$を満たすことである。
集合 \(X\) 上の自己写像 \(f: X \to X\) の全体は、合成 \(\circ\) に関してモノイド \(X^X\) をなす。\(f \in X^X\) が冪等元であるとは、\(f\) がモノイド \(X^X\) における冪等元であること、すなわち
$$ f \circ f = f $$を満たすことである。
API 呼び出しにおける冪等性
これを踏まえて、Web サービスの API のような「外部から内部状態が更新でき、同時に結果がレスポンスとして観測できる」場合の冪等性を考えてみる。
RFC 9110: HTTP Semantics の 9.2.2. Idempotent Methods では、「操作を N 回行った場合にサーバーに引き起こされる意図された効果が、1 回行った場合と同じ」(訳は筆者)とされている。つまり、サーバーを状態空間 \(X\) と見たとき \(X\) 上に引き起こされる効果が同じ、ということであって、API を呼び出したときのレスポンスについては何も要請されていない、というのが自分の理解である。
\(X\) を状態空間(サーバー)、\(S\) をレスポンスの集合として、API を呼び出す行為は次のようにモデリングできると考えた。
$$ g: X \to S \times X, \quad g(x) = (r(x), f(x)) $$- \(f : X \to X\) — 状態空間の状態遷移
- \(r : X \to S\) — レスポンスの(素朴な)モデル
\(r\) についてはあくまで近似である。例えば現在時刻を返すようなレスポンスまでは表現しきれていないと思う。このモデルでは、観測可能なのは \(r(x)\) だけであり、更新後の状態 \(f(x)\) は直接観測できない。
最初、\(r\) は更新後の状態の関数になるはずなので、観測しているのは \(r(f(x))\) のように書けるかと思ったのだが、それだと更新前の値にも依存するようなレスポンスを返すクエリ(DELETE など)が表現できない。\(g(x) = (r(x), f(x))\) のように「呼び出し時点の状態 \(x\)」に対して \(r\) を適用する形にすれば、この問題は起きない。
こう書いたとき、\(f\) が \(X^X\) の冪等元となることが冪等性の定義であり、\(r\)、\(g\) そのものには冪等性を要求しない(そもそも \(r \circ r\) 等は定義できない)、というのが私の理解である。つまり、特定の条件を課さなければ、返ってくるレスポンスのみから冪等性は判定できない。
クエリパラメータを含めた一般化
RFC の定義は「同じ操作を N 回行った場合」という前提を置いている。つまり暗黙のうちにクエリ(リクエスト内容)は固定されている。これを明示するために、クエリの集合 \(R\) を導入して、\(h: R \times X \to S \times X\) と、クエリと状態からレスポンスと状態への写像として考えてみる。冪等性を考える場合は、クエリを1回目・2回目とも同じ \(a \in R\) に固定し、\(\tilde{g}(x) = h(a, x)\) とした上で、この \(\tilde{g}\) の状態成分(\(X\) 側)が冪等元になっているかどうかを考えている。
単純に見えて自分が混乱していた例として、\(x \mapsto x+1\) のように、レスポンスをそのまま次のクエリの入力として使い回すケースがある。上の \(h(a, x)\) の枠組みで言えば、これは2回目の呼び出しのクエリ \(a\) が1回目の \(a\) と違う値になっている、ということなので、そもそも \(\tilde{g}(x) = h(a,x)\)(同じ \(a\) を固定した写像)の冪等性を論じる対象になっていない。
上の例を \(f: \mathbb{Z} \to \mathbb{Z}\) という数学的な世界だけで考えれば当然合成は定義できるが、API に関して言えば、レスポンスの型 \(S\) と状態の型 \(X\) は別物なので、レスポンスをそのまま次の状態として代入し直せるとは限らない。今回はたまたま \(S = X\) のようになっていて代入できてしまっているだけで、一般には常にできるわけではない。
レスポンスから \(f\) の冪等性を確認できるか
もし \(r\) が \(f\) の結果だけに依存する形
$$ r = \tilde{r} \circ f, \qquad \tilde{r}: X \to S $$に書けて、かつ \(\tilde{r}\) が単射であれば、1回目の観測 \(r(x) = \tilde{r}(f(x))\) と2回目の観測 \(r(f(x)) = \tilde{r}(f(f(x)))\) が一致することから、\(\tilde{r}\) の単射性より \(f(f(x)) = f(x)\)、すなわち \(f\) が冪等であることが確認できる。
ただし、この \(\tilde{r}\) が単射になるかどうかは API の設計に強く依存する。例えば PUT/GET のレスポンスとして更新後・現在のリソースをまるごと返すような API であれば、レスポンスから状態がほぼ復元できるので単射に近づく。一方、200/404 のようなステータスコードや {“success”: true} のようなフラグだけを返す API では、多くの状態が同じレスポンスに潰れてしまうため(多対一)、一般には単射にならない。
注意したいのは、レスポンスが JSON で一見リッチに見えても、それだけでは単射性は保証されないという点である。タイムスタンプや内部カウンタ、他のリソースとの関連など、レスポンスに載っていない隠れた内部状態が存在すれば、見た目上豊富な表現を返していても実は非単射、ということは普通に起こり得る。
State モナド
Haskell だと \(g(x) = (r(x), f(x))\) は State モナドそのもの(State s a ≡ s -> (a, s)、s が状態、a が戻り値)と(AI に)教えてもらった。興味深いが、Haskell およびモナドの正確な意味を理解しておらず、自分の言葉で説明できない。残念である。