コンテンツへスキップ

決済の二重処理を防ぐHono冪等性ミドルウェアを作った

決済の二重処理を防ぐHono冪等性ミドルウェアを作った のアイキャッチ
Contents

    はじめに

    決済APIでPOSTリクエストが二重に飛んだら、請求が二重に発生します。ネットワークが不安定なモバイル環境では、クライアントがタイムアウトでリトライするのは日常茶飯事です。

    この問題を解決するのが冪等性キー(Idempotency-Key)パターンです。同じキーのリクエストは何度送っても結果が1回分になります。Stripeが広く知られる実装を持ち、現在IETFでdraft-ietf-httpapi-idempotency-key-headerとして標準化が進んでいます。

    Honoのエコシステムにはこのミドルウェアが存在しなかったので、hono-idempotencyとして作りました。

    GitHub - paveg/hono-idempotency: Stripe-style Idempotency-Key middleware for Hono with KV/D1/Memory storesgithub.com

    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を返します。

    各ストアでのロック実装は以下の通りです。

    ストアロック方式
    MemoryMapのin-processチェック
    RedisSET key value NX EX ttl(単一コマンドでアトミック)
    KVget → check → put(結果整合性のため完全なアトミック性はない)
    D1INSERT 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-apihono-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-61
    const 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>でクライアントも型安全

    インストールと始め方

    Terminal window
    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は歓迎です。気になった点があればお気軽にどうぞ。

    X Facebook B! Hatena