ASP.NET Core 二重送信に強いAPIを作る① 操作の単位とキーを決める
送信ボタンを押したあとに通信が切れたとき、「失敗したからもう一度送る」と考えることがあります。
しかし、サーバーでは保存が終わっていて、応答だけが届かなかったのかもしれません。単純に新規登録を繰り返すと、同じ内容が2件作られる可能性があります。
今回は問い合わせ受付を例に、同じ操作を再送しても受付が増えないAPIとReactの画面を作ります。
連打と再送を分けて考える
| 起きること | 対策する場所 |
|---|---|
| 同じ画面で送信ボタンを続けて押す | 画面で送信中の操作を止める |
| 保存後に応答が届かず再送する | APIで同じ操作の結果を返す |
| 同じ操作がサーバーへ同時に届く | DBの一意制約で登録を1件にする |
| 別々のキーで同じ内容を送る | 今回の重複防止では別の操作として扱う |
ボタンの無効化は必要ですが、通信経路や別のクライアントから届くリクエストには効きません。画面とサーバーで役割を分けます。
冪等性キーは、一度の操作を識別する値
同じ操作を繰り返しても、業務データへの影響が増えない性質を冪等性と呼びます。ここでは、問い合わせを1件受け付ける操作に、ランダムなキーを付けます。
同じ問い合わせの再送操作キーA + 件名と本文 → 受付番号X
操作キーA + 同じ内容 → 同じ受付番号X一方、別の問い合わせを作るときは別のキーを使います。
新しい問い合わせ操作キーB + 件名と本文 → 新しい受付番号Y同じ本文だから必ず同じ問い合わせとは限りません。内容だけで重複と判断せず、利用者が始めた操作の単位をキーで識別します。
今回のAPI契約を決める
POST /api/inquiriesに、次のJSONを送ります。
問い合わせの本文{
"subject": "通知設定について",
"message": "通知を受け取る時間帯を変更できますか。"
}操作キーはIdempotency-KeyヘッダーにUUID形式で渡します。この連載で採用するアプリの契約です。すべてのAPIが同じ形式や保存期間を持つわけではありません。
| 条件 | 結果 |
|---|---|
| 未使用のキーと有効な内容 | 1件保存し、受付番号を返す |
| 同じ利用者・同じキー・同じ内容 | 保存済みの受付番号と受付時刻を返す |
| 同じ利用者・同じキー・異なる内容 | 409で拒否する |
| キーの形式や入力内容が不正 | 400で拒否する |
| 未ログイン | 401で拒否する |
初回と再送のどちらも、成功時には201と同じ形のJSONを返します。「今回初めて作ったか」を画面へ伝えるのではなく、同じ操作の受付結果を再現する契約です。
成功レスポンスの例{
"receiptId": "cce5979d-7a22-46e8-a72d-a6219cb2df35",
"receivedAt": "2026-10-23T02:00:00+00:00"
}同一に保つのは、この受付番号と受付時刻です。HTTPのDateヘッダーやログの追跡IDまで同じになることは要求しません。
利用者とキーを組み合わせる
キーだけを検索すると、別の利用者が同じキーを送った場合に、他人の受付結果を返す危険があります。
今回は、認証済みの利用者IDと操作キーの組み合わせを一意にします。利用者IDは本文から受け取らず、認証情報から取得します。
問い合わせ専用テーブルなので、操作の種類はテーブル自体で区別できます。複数APIのキーを共通テーブルへ保存する設計では、さらに操作の種類やAPIのバージョンも一意性の範囲に含める必要があります。
比較する内容を先に正規化する
件名と本文の前後の空白を除き、本文の改行をLFへそろえます。その結果を保存し、再送時も同じ方法で比較します。
ReceiptModels.cspublic sealed record InquiryInput(string? Subject, string? Message);
public sealed record InquiryBody(string Subject, string Message);
public sealed record Receipt(Guid Id, string Subject, string Message, DateTimeOffset ReceivedAt);
public sealed record ReceiptResult(Guid ReceiptId, DateTimeOffset ReceivedAt);
public static class InquiryRules
{
public static InquiryBody Normalize(InquiryInput input) => new(
input.Subject?.Trim() ?? "",
(input.Message ?? "").Replace("\r\n", "\n").Replace("\r", "\n").Trim());
public static bool IsValid(InquiryBody body) =>
body.Subject.Length is >= 1 and <= 100 &&
body.Message.Length is >= 1 and <= 2000;
public static bool Matches(Receipt saved, InquiryBody body) =>
saved.Subject == body.Subject && saved.Message == body.Message;
}今回は項目が2つだけなので、保存済みの値を直接比較します。JSON文字列をそのまま比較すると、プロパティの順序や空白の違いまで別の要求として扱う可能性があります。
ハッシュを使う方法もありますが、どの項目をどの順番で正規化するかは、ハッシュを導入しても決める必要があります。基本的な考え方は冪等性キーでAPIの二重登録を防ぐ方法で説明しています。
保存した受付情報は変更しない
このシリーズでは、受付時の件名、本文、キー、受付時刻を変更しません。後から問い合わせの対応状況を管理するなら、別の列やテーブルへ保存します。
再送の比較対象を現在の編集済み本文にしてしまうと、最初は正しかった再送が「異なる内容」と判定されるためです。
また、受付レコードを削除すると、そのキーを送った再送を新規として受け付けてしまいます。保存期間の設計は最終回で扱います。
ファイルの役割
- backend
- Inquiries
- ReceiptModels.cs
- ReceiptStore.cs
- InquiriesController.cs
- Inquiries
- frontend
- src
- features
- inquiries
- inquiryApi.ts
- InquiryForm.tsx
- inquiries
- features
- src
既存のCookie認証があるアプリに追加する例です。C#は.NET 8、C# 12を対象にします。会員管理や問い合わせ一覧は既存の機能へ接続してください。
次は受付結果をDBに保存するで、同時に届いた再送にも対応するAPIを作ります。