ASP.NET Core 冪等性キーでAPIの二重登録を防ぐ
登録APIの処理が成功した直後に通信が切れると、クライアントには結果が届きません。
同じリクエストを再送すると、サーバー側では2件目のデータを作成してしまう可能性があります。
今回はクライアントが送る冪等性キーをデータと一緒に保存し、同じ要求を再送しても同じ結果を返す方法を紹介します。
ボタンを無効にするだけでは防げない
送信中にボタンを無効にすると、画面上の連打は抑えられます。
しかし、以下の再送はサーバー側で対処する必要があります。
- タイムアウト後にクライアントが再試行する
- 通信経路の途中でリクエストが再送される
- 複数のブラウザや処理から同じ要求が届く
冪等性キーは、1回の業務操作を識別する値です。同じ操作を再送するときは同じキーを使います。
テーブルに冪等性キーを保存する
注文を例に、クライアントIDと冪等性キーの組み合わせへ一意制約を付けます。
ordersテーブルCREATE TABLE orders (
order_id uuid PRIMARY KEY,
client_id uuid NOT NULL,
idempotency_key varchar(128) NOT NULL,
request_hash varchar(64) NOT NULL,
product_code varchar(50) NOT NULL,
quantity integer NOT NULL,
created_at timestamptz NOT NULL,
CONSTRAINT uq_orders_idempotency
UNIQUE (client_id, idempotency_key)
);キーをシステム全体で一意にすると、別のクライアントが偶然同じ値を使った場合も衝突します。そのため、認証されたクライアントIDなどと組み合わせます。
request_hashは、同じキーで異なる内容が送られたことを検出するために保存します。
クライアントでキーを生成する
ブラウザではcrypto.randomUUID()でキーを生成できます。
注文を送信する例type CreateOrder = {
productCode: string;
quantity: number;
};
const idempotencyKey = crypto.randomUUID();
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
productCode: 'ITEM-001',
quantity: 2,
} satisfies CreateOrder),
});タイムアウト後に再送する場合は、新しいUUIDを作らず、最初に生成したidempotencyKeyを再利用します。
利用者が内容を変更して新しく送信する場合は、別の業務操作なので新しいキーを使用します。
リクエスト内容のハッシュを作成する
同じキーで内容が異なる場合に、以前の注文をそのまま返すと間違いに気付きにくくなります。
比較に使用する値を決まった順序で連結し、SHA-256でハッシュ化します。
リクエストハッシュの作成using System.Security.Cryptography;
using System.Text;
static string CreateRequestHash(CreateOrderRequest request)
{
var canonical = $"{request.ProductCode.Trim()}\n{request.Quantity}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
return Convert.ToHexString(bytes);
}JSON文字列をそのままハッシュ化すると、プロパティの順序や空白だけで値が変わる場合があります。
業務上同じと判断する項目を正規化してからハッシュ化します。
トランザクション内で登録する
以下はNpgsqlを使用した処理例です。
OrderService.cspublic async Task<OrderResult> CreateAsync(
Guid clientId,
string idempotencyKey,
CreateOrderRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(idempotencyKey) || idempotencyKey.Length > 128)
{
throw new ArgumentException("冪等性キーが不正です。", nameof(idempotencyKey));
}
var requestHash = CreateRequestHash(request);
await using var transaction = await _dataSource.BeginTransactionAsync(cancellationToken);
var existing = await FindAsync(
transaction,
clientId,
idempotencyKey,
cancellationToken);
if (existing is not null)
{
if (existing.RequestHash != requestHash)
{
throw new IdempotencyConflictException();
}
await transaction.CommitAsync(cancellationToken);
return new OrderResult(existing.OrderId, Created: false);
}
var orderId = Guid.NewGuid();
try
{
await InsertAsync(
transaction,
orderId,
clientId,
idempotencyKey.Trim(),
requestHash,
request,
cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new OrderResult(orderId, Created: true);
}
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
{
await transaction.RollbackAsync(cancellationToken);
return await ReadResultAfterConflictAsync(
clientId,
idempotencyKey,
requestHash,
cancellationToken);
}
}最初に既存データを検索しても、別のリクエストが検索直後に同じキーを登録する可能性があります。
一意制約を最後の防波堤にして、競合した場合は登録済みの結果を読み直します。
ReadResultAfterConflictAsyncでもハッシュを比較し、内容が違う場合は競合エラーにします。
Controllerでキーを受け取る
冪等性キーをHTTPヘッダーから受け取ります。
OrdersController.cs[ApiController]
[Route("api/orders")]
public sealed class OrdersController(OrderService service) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(
[FromHeader(Name = "Idempotency-Key")] string idempotencyKey,
[FromBody] CreateOrderRequest request,
CancellationToken cancellationToken)
{
var clientId = GetAuthenticatedClientId();
var result = await service.CreateAsync(
clientId,
idempotencyKey,
request,
cancellationToken);
if (result.Created)
{
return CreatedAtAction(
nameof(Find),
new { orderId = result.OrderId },
result);
}
return Ok(result);
}
}初回は201 Created、同じ要求の再送には200 OKを返す例です。APIの契約によっては、保存した初回レスポンスと同じステータス・本文を返す方法もあります。
失敗した処理をどう扱うか決める
この例では、注文と冪等性キーを同じ行へ保存しています。トランザクションが失敗すれば両方とも保存されないため、同じキーで再試行できます。
外部サービスの呼び出しを含む場合は、データベースだけで原子的に処理できません。外部処理をOutboxへ保存し、同じ業務IDで重複実行を防ぐ方法などを組み合わせます。
Transactional Outboxで外部処理を確実に実行する方法
入力エラーのように何度送っても同じ結果になるエラーを保存する設計もあります。一時的な障害まで保存すると、同じキーでは回復後も成功できません。
どの結果を記録するか、キーを保持する期間、期限切れデータの削除方法をAPIごとに決めます。
動作を確認する
| 操作 | 期待する結果 |
|---|---|
| 新しいキーで送信 | 注文を1件作成する |
| 同じキー・同じ内容で再送 | 最初の注文IDを返す |
| 同じキー・違う内容で再送 | 409 Conflictなどを返す |
| 同じキーを同時に送信 | 一意制約により1件だけ作成する |
| 別のクライアントが同じキーを使用 | それぞれ1件ずつ作成できる |
冪等性キーは、再試行を禁止する仕組みではありません。同じ操作を安全に再試行できるようにする仕組みです。