はじめに
決済APIでPOSTリクエストが二重に飛んだら、請求が二重に発生します。ネットワークが不安定なモバイル環境では、クライアントがタイムアウトでリトライするのは日常茶飯事です。
この問題を解決するのが冪等性キー(Idempotency-Key)パターンです。同じキーのリクエストは何度送っても結果が1回分になります。Stripeが広く知られる実装を持ち、現在IETFでdraft-ietf-httpapi-idempotency-key-headerとして標準化が進んでいます。
Honoのエコシステムにはこのミドルウェアが存在しなかったので、hono-idempotencyとして作りました。
TL;DR
Idempotency-KeyヘッダーでPOST/PATCHの二重処理を防止するHonoミドルウェア- IETF draft準拠 + Stripeの非キャッシュパターンを採用
- 5行で導入可能。ストアを差し替えるだけで本番運用できる
- Memory / Redis / Cloudflare KV / D1 / Durable Objects の5種類のストアアダプターを同梱
- RFC 9457 Problem Detailsによる構造化エラーレスポンス
import { Hono } from 'hono';import { idempotency } from 'hono-idempotency';import { memoryStore } from 'hono-idempotency/stores/memory';
const app = new Hono();app.use('/api/*', idempotency({ store: memoryStore() }));なぜ作ったのか
Honoのエコシステムには冪等性ミドルウェアがありませんでした。
IETF draftの仕様面では、sushichan044氏のZennの解説記事が非常に参考になりました。仕様の読み方やミドルウェア設計の考え方として学びが多かったです。
hono-idempotencyは今日インストールして、今日本番に入れられることを最優先で設計しています。
設計思想
1. Stripeパターンの採用
Stripeの冪等性実装は実戦で検証された設計です。hono-idempotencyはこれを参考にしています。
非2xxレスポンスはキャッシュしません。 ハンドラーがエラーを返した場合、ストアからキーを削除します。クライアントは同じキーでリトライ可能です。キャッシュするのは成功レスポンスだけです。
// middleware.ts L102-107 の実際のコードconst res = c.res;if (!res.ok) { // Non-2xx: delete key (Stripe pattern) so client can retry await store.delete(storeKey); return;}これはIETF draftの「キャッシュしてエラーを返し続ける」アプローチとは異なります。Stripeパターンを選んだ理由は、クライアント側のリトライ設計がシンプルになるからです。失敗したら同じキーでリトライすればよいだけです。
2. リクエストの同一性検証
同じキーで異なるリクエストボディが送られた場合、422 Unprocessable Entityを返します。識別ハッシュはWeb Crypto APIのSHA-256で生成しています。
// fingerprint.ts — Web Standards準拠、どのランタイムでも動くconst data = `${method}:${path}:${body}`;const encoded = new TextEncoder().encode(data);const hashBuffer = await crypto.subtle.digest('SHA-256', encoded);crypto.subtleはCloudflare Workers、Deno、Bun、Node.js 20+のすべてで利用可能なWeb Standardです。外部依存はありません。
3. 楽観的ロック
ストアのlock()はアトミックな操作を要求します。2つのリクエストが同時に同じキーでロックを試みた場合、一方だけがtrueを返し、他方はfalseを返します。
各ストアでのロック実装は以下の通りです。
| ストア | ロック方式 |
|---|---|
| Memory | Mapのin-processチェック |
| Redis | SET key value NX EX ttl(単一コマンドでアトミック) |
| KV | get → check → put(結果整合性のため完全なアトミック性はない) |
| D1 | INSERT OR IGNORE ... WHERE NOT EXISTS(SQLレベルのアトミック性) |
| Durable Objects | 単一書き込みモデル(ランタイムが排他性を保証) |
4. RFC 9457 Problem Details
エラーレスポンスはRFC 9457に準拠したapplication/problem+json形式です。codeフィールドでプログラムからエラーを識別しやすい形式になっています。
{ "type": "https://hono-idempotency.dev/errors/conflict", "title": "A request is outstanding for this idempotency key", "status": 409, "detail": "A request with the same idempotency key is currently being processed", "code": "CONFLICT"}4種類のエラーコード: MISSING_KEY(400)、KEY_TOO_LONG(400)、FINGERPRINT_MISMATCH(422)、CONFLICT(409)。
ストアアダプター
ストアは用途に応じて差し替え可能です。インターフェースは5つのメソッドだけで構成されています。
interface IdempotencyStore { get(key: string): Promise<IdempotencyRecord | undefined>; lock(key: string, record: IdempotencyRecord): Promise<boolean>; complete(key: string, response: StoredResponse): Promise<void>; delete(key: string): Promise<void>; purge(): Promise<number>;}開発: Memory Store
プロセス内Mapを使用します。セットアップ不要で開発時に便利です。maxSizeを設定すれば、上限を超えたときに古い順から自動削除されます。
import { memoryStore } from 'hono-idempotency/stores/memory';const store = memoryStore({ ttl: 24 * 60 * 60 * 1000, maxSize: 10000 });Node.js本番: Redis Store
ioredis、node-redis、@upstash/redisに対応しています。SET NX EXによる単一コマンドでのアトミックロックは、全ストア中最も強力なロック保証です。
import { redisStore } from 'hono-idempotency/stores/redis';import Redis from 'ioredis';
const store = redisStore({ client: new Redis(), ttl: 86400 });RedisClientLikeという最小インターフェースで抽象化しているため、@cloudflare/workers-typesのようなプラットフォーム固有の型への依存はありません。
Cloudflare本番: KV / D1 / Durable Objects
Cloudflare Workersでは3つの選択肢があります。
- KV は結果整合性ですが最もシンプルです。TTLはKVの
expirationTtlで自動管理されます - D1 は SQL による強整合性を提供し、
INSERT OR IGNOREでアトミックロックを実現します。テーブルは自動作成されます - Durable Objects は単一書き込みモデルで最も強力な整合性保証を持ち、
get→ check →putがランタイムレベルで排他されます
// D1の例app.use('/api/*', async (c, next) => { const store = d1Store({ database: c.env.IDEMPOTENCY_DB }); return idempotency({ store })(c, next);});他のアプローチとの違い
Hono + 冪等性の実装例としてhono-idempotent-apiがあります。Stripeの冪等性パターンに着想を得た決済APIのPoCで、リクエストボディのSHA-256ハッシュから決定的UUIDを生成し、SQLite + インメモリMapの二層キャッシュで重複を検出する設計です。
このアプローチとhono-idempotencyの違いを整理します。
| hono-idempotent-api | hono-idempotency | |
|---|---|---|
| 形態 | アプリ内に直接実装 | ミドルウェアとして分離 |
| 冪等性キー | ボディのハッシュから自動生成 | クライアントがヘッダーで指定 |
| ストア | SQLite + Map(固定) | 5種類から選択(差し替え可能) |
| 同一性検証 | ボディのみ | method + path + body |
| 同一キー・別ボディの検出 | なし(キーがボディ由来のため) | 422 FINGERPRINT_MISMATCH |
| 同時リクエストの排他制御 | なし | 409 CONFLICTとRetry-After |
| エラー形式 | HTTPステータスのみ | RFC 9457 Problem Details |
大きな設計上の分岐はキーの生成主体です。
hono-idempotent-apiはサーバーがボディからキーを導出します。クライアントの実装負担がゼロで、仕組みとしてシンプルです。操作がボディで一意に決定されるAPI(例えばリソースの属性上書き)では、このアプローチは合理的に機能します。
一方、「同じボディだが別の意図」があるケースでは問題が起きます。典型的なのが決済で、同じカード・同じ金額で2回支払いたい場合、{card: "4242", amount: 1000} のハッシュは常に同じになるため、2回目が重複排除されてしまいます。クライアントが別のキーを振れば、それぞれ独立した決済として処理できます。
IETF draftがクライアント指定を採用しているのは、汎用仕様としてボディ=操作の前提を置けないからだと考えています。hono-idempotencyもこれに従っています。
hono-idempotencyの立ち位置は必要なものが一式揃ったミドルウェアです。ストアアダプターを5種類同梱し、npm installした時点でMemoryからRedis・Cloudflare D1まで選べます。
セキュリティ上の考慮点
本番運用で気をつけるべき点をいくつか紹介します。
キーの不正操作対策
ストアキーはencodeURIComponentで安全にエスケープされます。ユーザーが:を含むキーを送っても、区切り文字が混同されてキーの境界が壊れることはありません。
// middleware.ts L59-61const encodedKey = encodeURIComponent(key);const baseKey = `${c.req.method}:${c.req.path}:${encodedKey}`;const storeKey = rawPrefix ? `${encodeURIComponent(rawPrefix)}:${baseKey}` : baseKey;Set-Cookieヘッダーの除外
キャッシュ済みレスポンスを返す際、Set-Cookieヘッダーは除外されます。セッションCookieが別ユーザーに再送されるのを防ぐためです。
フックのエラー隔離
onCacheHit/onCacheMissのエラーは握り潰されます。監視用フックのバグが冪等性保証を壊さないようにする設計です。
Honoの型システムとの統合
Hono RPCクライアントと組み合わせると、エンドツーエンドの型安全性が得られます。
import type { IdempotencyEnv } from 'hono-idempotency';
const app = new Hono<IdempotencyEnv>() .use('/api/*', idempotency({ store: memoryStore() })) .post('/api/payments', (c) => { const key = c.get('idempotencyKey'); // string | undefined として型付け return c.json({ id: 'pay_123', key }); });
// hc<typeof app>でクライアントも型安全インストールと始め方
pnpm add hono-idempotency最小構成:
import { Hono } from 'hono';import { idempotency } from 'hono-idempotency';import { memoryStore } from 'hono-idempotency/stores/memory';
const app = new Hono();app.use('/api/*', idempotency({ store: memoryStore() }));
app.post('/api/payments', (c) => { return c.json({ id: crypto.randomUUID(), status: 'succeeded' }, 201);});本番(Redis):
import { redisStore } from 'hono-idempotency/stores/redis';import Redis from 'ioredis';
app.use( '/api/*', idempotency({ store: redisStore({ client: new Redis() }), required: true, onCacheHit: (key) => console.log(`replayed: ${key}`), }),);本番(Cloudflare D1):
import { d1Store } from 'hono-idempotency/stores/cloudflare-d1';
app.use('/api/*', async (c, next) => { const store = d1Store({ database: c.env.IDEMPOTENCY_DB }); return idempotency({ store, required: true })(c, next);});まとめ
hono-idempotencyは、Honoアプリケーションに冪等性保証を追加するミドルウェアです。
- IETF draft準拠のIdempotency-Keyヘッダー処理
- Stripeパターンによる実用的なキャッシュ戦略
- 5種類のストアで開発から本番まで対応
- 100%テストカバレッジで品質を担保
- RFC 9457による構造化エラー
GitHub: https://github.com/paveg/hono-idempotency
npm: https://www.npmjs.com/package/hono-idempotency
Issue/PRは歓迎です。気になった点があればお気軽にどうぞ。