お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」で、記事の「いいね」「興味なし」に関する不具合を直しました。DevPickは、開発関連のニュースサイトをAIが横断してスコアリングし、その人の興味に合ったものだけを届けるWebアプリです。
保存に失敗したときの「再試行」ボタン自体は、以前から用意していました。問題は、そのボタンが送り直す要求の形にありました。今回は、再試行が保存の取り消しになりうると分かってから、APIの受け取り方そのものを変えるまでの経緯を書きます。
「再試行」ボタンが、いいねを消す経路になっていた
DevPickでは、記事一覧の各カードに「いいね」と「興味なし」のボタンがあります。押すとその場でサーバーへ保存し、応答が返ってきたら画面の表示を更新します。保存に失敗したときは、そのカードの中にエラーの案内と「再試行」ボタンを出す作りにしていました。
この再試行は、失敗した操作をもう一度やり直すだけの単純な処理です。押されたら、最初に送ったのと同じ保存要求をそのまま送り直します。素直な実装に見えますし、実際に通信が完全に失敗していたなら、これで正しく復旧します。
問題は「完全に失敗していたなら」という前提のほうでした。サーバーには保存要求が届いていて、保存も終わっているのに、その応答だけがこちらへ返ってこない場合があります。アプリから見れば失敗ですが、サーバーから見れば成功です。この状態で再試行を押すと、サーバーには同じ要求が2回届くことになります。
そして、いいね・興味なしの保存要求は、届くたびに状態を反転させる作りでした。つまり、1回目で付いたいいねが、再試行の2回目で外れます。利用者は保存し直したつもりなのに、実際には保存した内容を取り消していたわけです。
以前、メール送信の再試行で先に届いた復旧リンクが無効になっていた問題を直したときも、原因は「前の試行は失敗した」と決めつけていたことでした。今回は場所も処理も違いますが、同じ思い込みが別の形で残っていたことになります。
IT女子 アラ美トグルAPIは、同じ要求を2回送ると元に戻る
原因をひとことで言うと、いいね・興味なしのAPIがトグルだったことです。トグルとは、押すたびに入と切が入れ替わる仕組みのことで、照明のスイッチと同じ動きをします。DevPickのAPIも、要求を受け取ると「今いいねが付いているなら外す、付いていないなら付ける」という判定をしていました。
単純化すると、次のような処理です。
def toggle_like(profile_id, article_id):
existing = find_like(profile_id, article_id)
if existing:
delete_like(existing) # 付いていたので外す
return {"liked": False}
add_like(profile_id, article_id) # 付いていなかったので付ける
return {"liked": True}
この処理は、呼ばれた回数だけ状態が反転します。1回呼べばいいねが付き、2回呼べば元に戻ります。画面のボタンを人が2回押したのなら、それは正しい挙動です。付けて、外した。利用者の意図どおりです。
ところが再試行は、人が2回押したのとは意味が違います。利用者の意図は1回分「付ける」であって、送信が2回になったのは通信の都合にすぎません。要求の回数と意図の回数がずれるのに、APIは回数のほうしか見ていませんでした。
こういうときに出てくるのが冪等性という言葉です。同じ要求を何度送っても、結果が1回送ったときと変わらない性質のことを指します。難しく聞こえますが、要は「送り直しても壊れないか」という話です。トグルは、この性質を持っていません。だから、送り直しても安全かどうかを確かめないまま再試行ボタンを付けたのが間違いでした。
再試行という機能は、単体では正しく見えます。危ないのは、送り直す相手のAPIが回数に依存しているときだけです。ボタンの実装ではなく、その組み合わせを見落としていました。



サーバーに現在の状態を聞く案を採らなかった理由
直し方の候補は2つありました。1つは、再試行の前にサーバーへ「今この記事のいいねはどうなっていますか」と問い合わせ、その答えを見てから送るかどうかを決める方法です。もう1つは、APIの受け取り方そのものを変えて、送り直しても壊れない形にする方法です。
最初に思い付くのは前者のほうです。状態が分からないのだから確かめればいい、という発想は自然ですし、APIの形を変えずに済みます。それでも採りませんでした。
理由は、その問い合わせ自体も通信だからです。今まさに問題になっているのは、通信の応答が返ってこない状況です。その最中に「状態を確認するための通信」を挟んでも、同じ理由で失敗しえます。そして問い合わせが失敗したとき、こちらには何も分かりません。保存できているのかいないのか判定できないまま、送るか送らないかを決めることになります。
つまりこの案は、判定できない場面を後ろへ1つずらしただけで、無くしてはいません。しかも通信が1往復増えるぶん、失敗する機会はむしろ増えます。復旧のために足した処理が、復旧できない条件を新しく作るのでは意味がありません。
前に古い記事の掃除処理で直近の既読が消える競合を直したときも、確認の手順を増やすより、そもそも危険な条件で削除しない構造へ倒すほうが効きました。確認を足す方向は、確認と実行の間に隙間が残ります。今回も同じで、判定を挟むより、判定しなくて済む形にするほうが素直でした。
そこで後者、つまりAPIを冪等な形へ変える方向に決めました。送り直しても結果が変わらないなら、そもそも「送っていいかどうか」を判定する必要がありません。



「意図した最終状態」を送る形へ変えた
変更の中心は、APIが受け取る内容を「切り替えてください」から「この状態にしてください」へ変えたことです。要求の本文に、利用者が意図した最終状態を真偽値で入れて送るようにしました。付けたいなら真、外したいなら偽です。
サーバー側は、受け取った最終状態と現在の保存内容を突き合わせ、必要なときだけ書き込みます。単純化すると、次のようになります。
def apply_like(profile_id, article_id, desired):
existing = find_like(profile_id, article_id)
if desired and existing is None:
add_like(profile_id, article_id)
return {"liked": True, "changed": True}
if not desired and existing is not None:
delete_like(existing)
return {"liked": False, "changed": True}
# 既に意図どおりの状態。何も書かない
return {"liked": desired, "changed": False}
これなら、同じ要求が2回届いても結果は変わりません。1回目で付いたいいねは、2回目の要求でも「付ける」を意図しているので、そのままです。応答を失った再試行が取り消しになる経路は、これで消えました。
画面側でも、あわせて直した点があります。再試行を押したときに、そのときの手元の状態から意図を求め直さないことです。手元では失敗扱いになっていて、サーバーには保存済み、という食い違いが起きうるからです。手元を見て求め直すと、また逆の意図を送ってしまいます。そのため、失敗した時点で「利用者が何をしたかったのか」を記録しておき、再試行ではそれをそのまま送り直すようにしました。
もう一つ、応答に「この呼び出しで実際に状態が変わったか」を表す値を追加しています。冪等にすると、変わったときと変わらなかったときの区別が応答から読めなくなるためです。この値は次のH2で書く件数の扱いに使います。
要求の本文が省略されたときは、従来どおりのトグルとして動かしています。すでに配布済みで、この項目を送らないクライアントが残っているためです。



件数の楽観更新と、旧クライアントとの互換
APIを冪等にすると、その周りで数を数えている処理に影響が出ます。DevPickでは、いいねの件数を画面に出しており、ボタンを押した時点で表示を1つ増やしています。サーバーの応答を待たずに先に反映する、いわゆる楽観的な更新です。反応が速く見えるので採っている作りですが、前提として「1回押したらサーバー側も1件増える」があります。
冪等にした後は、この前提が崩れます。応答を失った1回目が実は保存できていた場合、再試行はサーバーの件数を変えません。それなのに画面側だけがもう1つ増やすと、実際より多い数を表示してしまいます。
そこで、前のH2で追加した「実際に変わったか」の値を使い、変わらなかったときは増減させず、サーバーから件数を取り直して合わせるようにしました。ずれを推測で埋めず、正しい値を持っている側に聞き直すという方針です。
サーバー側にも似た調整があります。DevPickでは、いいねの傾向が変わるとスコアの計算結果を捨てて計算し直します。ここで、状態が変わらなかった再送でも捨ててしまうと、再試行のたびにその人の記事全体を無駄に計算し直すことになります。実際に変わったときだけ捨てるようにして、再試行が重い処理を呼ばないようにしました。
そしてもう一つが、古いクライアントとの互換です。Chrome拡張は配布済みのものが利用者の手元にあり、こちらの都合で一斉に入れ替えることはできません。新しい項目を送らない要求が届いても、従来どおりのトグルとして動くようにしてあります。加えて、新しい画面が古いサーバーへ繋がる場合も考えました。デプロイの途中では、この組み合わせが一時的に起こりえます。この場合、応答に「変わったか」の値が入っていないので、画面側は従来と同じ動きに留まるようにしています。
冪等にする変更は、APIの入口だけを見れば小さな修正に見えます。しかし実際には、件数の数え方、キャッシュを捨てる条件、配布済みクライアント、デプロイ途中の食い違いまでが一続きでした。



まとめ
今回の問題は、再試行ボタンの実装ミスではありませんでした。押すたびに状態が反転するAPIに対して、同じ要求をそのまま送り直していたことが原因です。サーバーには保存できていて応答だけを失った場合、その再試行は保存の取り消しになります。
対策として、APIが受け取る内容を「切り替えてください」から「この状態にしてください」へ変え、同じ要求が何度届いても結果が変わらないようにしました。再試行では、手元の状態から意図を求め直さず、失敗した時点で記録した意図をそのまま送り直します。件数の楽観的な更新やスコアの計算し直しも、実際に状態が変わったときだけ動くよう揃えました。
今の私の結論は、失敗したときの再試行を用意するなら、その要求が送り直しても壊れない形かどうかを先に確かめるべきだ、ということです。再試行ボタン自体はどこにでもある機能ですが、送り直す相手が回数に依存していると、復旧の手段がそのまま破壊の手段になります。冪等性という言葉を難しく捉える必要はなくて、「もう一度送っても大丈夫か」を一度考えるだけで、この種の事故は避けられると感じました。












