zukucode
主にWEB関連の情報を技術メモとして発信しています。

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.cs
public 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件ずつ作成できる

冪等性キーは、再試行を禁止する仕組みではありません。同じ操作を安全に再試行できるようにする仕組みです。


関連記事