はじめに
Hono には、すでに paveg/hono-idempotency のような Idempotency-Key ミドルウェアがあります。汎用ミドルウェアとしてよく整理されていて、まず候補になります。
ただ、自分の用途では次の条件がありました。
- PostgreSQL を保存先にしたい
- テナントごとにキーの名前空間を分けたい
- 同じキーで別本文が来たら
422を返したい - 並行リクエストで
409が返ったときだけ再確認したい -
status: partialのような部分失敗は保存したくない
そのため、Hono のミドルウェアとして自前実装する形にしました。この記事は、そのときの判断メモです。
環境
- Hono 4.x(Node.js アダプター)
- node-postgres (
pg) 8.13.x - PostgreSQL 14 以上
何を自前実装の責務にするか
Idempotency-Key の実装で最低限必要なのは、次の 4 点です。
- どのリクエストを同じ再試行とみなすか
- 保存済みレスポンスがあれば再利用すること
- 同じキーで別本文が来たら拒否すること
- 並行リクエストによる競合を吸収すること
今回の実装では、キーの識別単位を次の組み合わせにしました。
tenant_id
HTTP メソッド
パス
Idempotency-Key
Idempotency-Key の文字列だけで見ない理由は、同じテナント内の別エンドポイントでも同じキーが使われるためです。
Hono でミドルウェアにしやすい理由
Hono では、await next() の前後に処理を差し込めるので、Idempotency-Key はミドルウェアとして置きやすいです。
やることは大きく 3 つです。
- ハンドラーへ入る前にキーと本文ハッシュを確認する
- 保存済みレスポンスがあればその場で返す
- ハンドラが成功した後で、そのレスポンスを保存する
Hono では、ミドルウェア側で c.req.text() を読んだ後でも、後段で c.req.json() を扱いやすいので、本文ハッシュの計算を先に置きやすいのが助かります。
本文ハッシュは JSON のキー順をそろえて作る
同じ Idempotency-Key でも、本文が違えば別リクエストです。そのため、キーだけでなく本文のハッシュも保存します。
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;
}
}
const bodyText = await c.req.text();
const requestHash = createHash('sha256')
.update(canonicalize(bodyText))
.digest('hex');
これは厳密な canonical JSON 実装ではありませんが、実務上の JSON API ではこの程度でも十分使えます。
先に保存済みレスポンスを探す
PostgreSQL には、Idempotency-Key と本文ハッシュ、レスポンス本文を保存します。
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',
},
});
}
ここでは JSON API 前提なので、レスポンス本文は 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],
);
});
ここはベストエフォート、つまり「保存だけ失敗しても元のレスポンスは返す」方針にしています。
ただし、この方針だと「本体処理は成功したが、冪等性レコードだけ保存できなかった」ケースは残ります。そこまで防ぐなら、本体処理と保存を同じトランザクションに入れるか、先に予約行を作る設計が必要です。
409 のときの再確認
同じ Idempotency-Key のリクエストが、ほぼ同時に 2 つ来ることがあります。
Request A:
SELECT → miss → handler → INSERT
Request B:
SELECT → miss → handler → 409
このとき後続リクエストが 409 になっても、それが本当に業務上の衝突なのか、先行リクエストが先に成功した影響なのかは分かりません。
そのため 409 のときだけ、保存済みレスポンスをもう一度確認します。
if (resAfter && resAfter.status === 409) {
const raceCached = await findCachedResponse();
if (raceCached) {
return buildCachedReplay(raceCached, requestHash);
}
}
毎回やるのではなく、409 のときだけ 1 回確認する、というのが実装上の落としどころでした。
部分失敗を保存しない理由
API によっては、HTTP ステータスが 200 でも本文で部分失敗を表すことがあります。
{ "status": "partial" }
こういうレスポンスを保存すると、同じキーで再試行しても保存済みの部分失敗が返るだけになります。
そのため、自分の実装では status: partial や status: failed を含むレスポンスは保存しないようにしました。
try {
const parsed = JSON.parse(resText) as Record<string, unknown>;
if (parsed.status === 'partial' || parsed.status === 'failed') {
return;
}
} catch {
// パースできない場合は、部分失敗とは判定しない
}
このあたりはライブラリというより、API 契約に依存する判断です。
テーブル未作成時の 503
idempotency_keys テーブルが存在しない状態でミドルウェアが動くと、PostgreSQL は 42P01 を返します。
これはクライアントの入力ミスではなく、サーバー側の準備不足です。そのため、自分の実装では 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;
}
paveg/hono-idempotency と比べると
paveg/hono-idempotency は、Hono で Idempotency-Key を扱うための汎用ミドルウェアとしてよく整理されています。まず既存実装を試す、という選択は自然です。
そのうえで、今回はこちらの要件をもう少し強く前提に置きたかった、という違いがありました。
- PostgreSQL 前提で組みたかった
- テナント、メソッド、パスを含めたキー空間を前提にしたかった
-
409の再確認を入れたかった - 部分失敗レスポンスを保存しない方針にしたかった
- テーブル未作成時の
42P01を503に変換したかった
これらの中には既存ミドルウェアでも拡張で吸収できそうなものがあります。ただ、今回は PostgreSQL 前提の保存設計から 409 時の再確認、部分失敗レスポンスの扱い、DB 未準備時のエラー変換まで、API 契約としてまとめてそろえたかったため、自前実装のほうが整理しやすいと判断しました。
つまり、違いは Hono で何ができるかというより、自分の API 契約をどこまでミドルウェアに持たせるか にあります。
まとめ
自前実装してみて、Hono で Idempotency-Key を扱うときに大事だったのは次の点でした。
- 本文ハッシュまで見て、同じ再試行かどうかを判定する
- キーの名前空間を
tenant_id、メソッド、パスまで含めて決める -
409のときだけ再確認して、よくあるレースを吸収する - 部分失敗を固定しない
- 保存失敗をどこまで許容するかを明示する
Hono は、これをミドルウェアとして差し込みやすいフレームワークでした。逆に言うと、設計の中心は Hono ではなく、Idempotency-Key をどういう契約で運用するかにあります。