1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Idempotency-Key をどう実装するかを Hono のミドルウェアを題材に考えた

1
Last updated at Posted at 2026-08-10

はじめに

Idempotency-Key は、同じ POST リクエストの重複実行を防ぐための仕組みです。

たとえば、注文作成 API がサーバー側では成功したものの、ネットワークの問題でクライアントがレスポンスを受け取れなかったとします。このときクライアントは、同じリクエストをもう一度送りたくなります。

1回目: POST /orders
  → サーバー側では成功
  → しかし、クライアントはレスポンスを受け取れなかった

2回目: POST /orders
  → 同じ処理がもう一度実行される
  → 注文が二重に作られる可能性がある

この重複実行を防ぐために、クライアントは一意のキーをヘッダに入れて送ります。

Idempotency-Key: 01HXYZ...

サーバー側は、そのキーと処理結果を保存しておき、同じキーの再試行が来たら処理を再実行せず、保存済みのレスポンスを返します。

この記事では、Idempotency-Key を使った重複実行の防止を扱います。実装例には Hono のミドルウェアを使いますが、考え方はほかの Web フレームワークでも応用できます。

ここでは、JSON を返す POST API を前提にします。保存先には PostgreSQL を使います。

まず押さえたいこと

Idempotency-Key を実装するときに重要なのは、単にレスポンスを保存することではありません。少なくとも次の点を決める必要があります。

  • どのリクエストを「同じ再試行」とみなすか
  • 同じキーで別の本文が来たときにどうするか
  • 並行リクエストが同時に来たときにどう扱うか
  • 何を保存し、何を保存しないか

今回の実装では、次の方針にします。

  • 対象は POST だけにする
  • Idempotency-Key とリクエスト本文のハッシュを PostgreSQL に保存する
  • キーの名前空間は、tenant_id、HTTP メソッド、パスで分ける
  • 同じキーで別の本文が来たら 422 を返す
  • 保存済みレスポンスがあればそのまま返す
  • 並行リクエストによる 409 だけ、DB を 1 回再確認する

Hono だとどこに置くか

Hono では、await next() の前後に処理を差し込めるので、Idempotency-Key はミドルウェアとして置きやすいです。

やることは大きく 3 つです。

  • ハンドラーへ入る前に Idempotency-Key と本文ハッシュを確認する
  • 保存済みレスポンスがあればそこで返す
  • ハンドラが成功した後で、そのレスポンスを保存する

Hono 固有の話として重要なのは、ミドルウェア側で c.req.text() を読んでも、後段のハンドラで c.req.json() を呼びやすいことです。そのため、本文ハッシュを先に計算しやすくなります。

同じキーでも本文まで同じとは限らない

Idempotency-Key が同じでも、リクエスト本文まで同じとは限りません。

1回目:
  Idempotency-Key: abc
  body: { "name": "Alice" }

2回目:
  Idempotency-Key: abc
  body: { "name": "Bob" }

この場合、同じキーだからといって 1 回目のレスポンスを返してはいけません。

そのため、サーバー側では Idempotency-Key だけでなく、本文のハッシュも保存します。保存済みのハッシュと今回のハッシュが一致すれば、同じリクエストの再試行とみなします。一致しなければ、同じキーを別のリクエストに使い回したものとして 422 Unprocessable Entity を返します。

キー順をそろえた JSON のハッシュ

JSON は、キーの順番が違っていても意味としては同じ場合があります。

{"a":1,"b":2}
{"b":2,"a":1}

この 2 つをそのまま文字列としてハッシュすると、別の値になります。そのため、本文が JSON なら、キーを再帰的に並べ替えてからハッシュします。

function sortJsonValue(value: unknown): unknown {
  if (Array.isArray(value)) {
    return value.map(sortJsonValue);
  }

  if (value && typeof value === 'object') {
    const obj = value as Record<string, unknown>;
    return Object.fromEntries(
      Object.keys(obj)
        .sort()
        .map((key) => [key, sortJsonValue(obj[key])]),
    );
  }

  return value;
}

function canonicalize(bodyText: string): string {
  try {
    const parsed: unknown = JSON.parse(bodyText);
    return JSON.stringify(sortJsonValue(parsed));
  } catch {
    return bodyText;
  }
}

これはあくまで簡易実装です。厳密な canonical JSON が必要な場合は、専用の実装を使う方が安全です。

const bodyText = await c.req.text();

const requestHash = createHash('sha256')
  .update(canonicalize(bodyText))
  .digest('hex');

キーの名前空間を決める

Idempotency-Key の一意性を、tenant_id とキー文字列だけで判断すると足りません。

POST /orders
  Idempotency-Key: abc

POST /payments
  Idempotency-Key: abc

同じテナント内で別の POST エンドポイントに同じキーが使われることはありえます。そのため、保存や検索の条件には少なくとも次を含めます。

tenant_id
HTTP メソッド
正規化したパス
Idempotency-Key

ここでの path は、クエリ文字列を除いたパスを想定します。たとえば /orders?debug=true ではなく /orders です。

保存済みレスポンスがあれば返す

最初に PostgreSQL から保存済みレスポンスを探します。

SELECT request_hash, response_status, response_body
  FROM idempotency_keys
 WHERE key = $1
   AND tenant_id = $2
   AND method = $3
   AND path = $4
   AND expires_at > NOW()

保存済みデータが見つかったら、本文ハッシュを確認します。

function buildCachedReplay(
  cached: IdempotencyRow,
  requestHash: string,
): Response {
  if (cached.request_hash !== requestHash) {
    throw ApiErrors.unprocessable(
      'idempotency_key_conflict',
      'Idempotency-Key reused with a different request body',
      'Idempotency-Key',
    );
  }

  return new Response(cached.response_body, {
    status: cached.response_status,
    headers: {
      'content-type': 'application/json',
      'cache-control': 'private, no-store',
      'x-content-type-options': 'nosniff',
      vary: 'Origin',
    },
  });
}

本文は TEXT で保存しておくと扱いやすくなります。JSONB だと内部的に正規化されるため、保存時の文字列表現をそのまま返したい用途には向きません。

保存が無い場合の通常処理

保存済みレスポンスがなければ、通常どおり後段のハンドラを実行します。

await next();
const res = c.res;

その後、成功レスポンスだけを保存します。

await withTenantClient(pool, auth.tenantId, async (client) => {
  await client.query(
    `INSERT INTO idempotency_keys (
       key,
       tenant_id,
       method,
       path,
       request_hash,
       response_status,
       response_body
     )
     VALUES ($1, $2, $3, $4, $5, $6, $7)
     ON CONFLICT (key, tenant_id, method, path) DO NOTHING`,
    [key, auth.tenantId, c.req.method, normalizedPath, requestHash, res.status, resText],
  );
}).catch((err: unknown) => {
  logError('idempotency.persist_failed', {
    key_preview: key.slice(0, 8),
    error: err,
  });
});

ここでは、保存に失敗しても元のレスポンスは壊さない方針にしています。

ただし、これは冪等性保証を少し弱めます。本体処理が成功したのに idempotency_keys への保存だけ失敗すると、次の再試行はキャッシュミスになります。その結果、同じ副作用がもう一度実行されます。

そこまで防ぐなら、本体処理と冪等性レコードの保存を同じトランザクションに入れるか、先に予約行を作る設計が必要です。

並行リクエストの 409 の再確認

同じ Idempotency-Key のリクエストが、ほぼ同時に 2 つ来ることがあります。

Request A:
  SELECT → miss → handler → INSERT

Request B:
  SELECT → miss → handler → 409

ここで後続リクエストが 409 Conflict になったとしても、それが本当に業務上の衝突なのか、先行リクエストが先に成功した影響なのかは分かりません。

そこで、409 が返ったときだけ、保存済みレスポンスをもう一度確認します。

if (resAfter && resAfter.status === 409) {
  const raceCached = await findCachedResponse();

  if (raceCached) {
    return buildCachedReplay(raceCached, requestHash);
  }
}

見つからなければ、元の 409 をそのまま返します。

部分失敗を保存しない理由

API によっては、HTTP ステータスが 200 でも本文で部分失敗を表すことがあります。

{ "status": "partial" }
{ "status": "failed" }

このようなレスポンスを保存すると、同じキーで再試行しても、保存済みの部分失敗が返るだけになります。

それでは再試行の意味がなくなるため、本文の statuspartialfailed の場合は保存しないようにします。

try {
  const parsed = JSON.parse(resText) as Record<string, unknown>;

  if (parsed.status === 'partial' || parsed.status === 'failed') {
    return;
  }
} catch {
  // パースできない場合は、部分失敗とは判定しない
}

テーブル定義

CREATE TABLE idempotency_keys (
  key             TEXT        NOT NULL,
  tenant_id       UUID        NOT NULL,
  method          TEXT        NOT NULL,
  path            TEXT        NOT NULL,
  request_hash    TEXT        NOT NULL,
  response_status SMALLINT    NOT NULL,
  response_body   TEXT        NOT NULL,
  expires_at      TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (key, tenant_id, method, path)
);

テーブルがまだ作られていない場合

idempotency_keys テーブルが存在しない状態でミドルウェアが動くと、PostgreSQL は 42P01、つまり undefined_table を返します。

これはクライアントの入力ミスではなく、サーバー側の準備不足です。そのため、503 Service Unavailable に変換すると分かりやすくなります。

try {
  cached = await findCachedResponse();
} catch (err) {
  if ((err as { code?: string })?.code === '42P01') {
    throw ApiErrors.storageUnavailable(
      'idempotency_storage_unavailable',
      'Idempotency storage is not provisioned',
    );
  }

  throw err;
}

全体の流れ

Hono 以外だとどう書くか

考え方そのものは Hono 固有ではありません。Idempotency-Key の実装は、次のようなフレームワークやライブラリでもよく出てきます。

どの実装でも中核は大きく変わりません。

  • ヘッダから Idempotency-Key を読む
  • リクエストの識別単位を決める
  • 保存済み結果を検索する
  • 必要なら本文の一致も確認する
  • 先行リクエストの結果を返すか、通常処理に進む

実装の置き場所やフックの形だけが、フレームワークごとに少しずつ違います。

こうした実装は、フレームワーク共通の定番ミドルウェアとして広く収束しているというより、各サービスの要件に合わせて少しずつ作り分けられていることが多いようです。何を「同じ再試行」とみなすか、どのレスポンスを保存するか、保存失敗をどこまで許容するかが業務ごとに違うためです。

まとめ

Idempotency-Key ミドルウェアは、単にレスポンスを保存するだけでは不十分です。

実務で使いやすくするには、少なくとも次の点を考える必要があります。

  • 同じキーで別の本文が来ていないか
  • 並行リクエストによる 409 をどう扱うか
  • 部分失敗を保存して再試行を邪魔しないか
  • 保存先が未準備のときにどう返すか

Hono は、これをミドルウェアとして実装しやすい題材です。ただし、考え方そのものは Hono 専用ではありません。Idempotency-Key の主題は、同じ再試行をどう見分け、どの結果を再利用するか にあります。

参考資料

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?