2014年から個人で iPhone/Android アプリ事業を運営してきましたが、累計5,000万DL を超えたあたりから「同じユーザーの同じ操作が二重に課金される」「同じ記事が二回投稿される」といった重複系の事故を何度も経験しました。最近 Claude Lab・Gemini Lab・Antigravity Lab・Rork Lab に加えて Lacrima・Mystery の 6サイトを並行運用するようになり、Gemini API への呼び出し回数が増えたタイミングで、また同じ問題に直面しました。今回は記事の自動生成パイプラインで「同じトピックの記事が二重に push される」という事故が起きました。
リトライは正義です。ネットワーク障害・サーバー側のスロットリング・タイムアウトに対する第一の防衛線で、これを諦めると本番運用が成り立ちません。一方で、リトライは重複の温床にもなります。Gemini API には公式の Idempotency-Key ヘッダーが存在しないため、クライアント側で再現性を確保する必要があります。今回は、5ヶ月にわたって 6サイトで運用してきた冪等性キー設計を、コードと実数値とともに整理して共有します。
なぜ Gemini API には公式の冪等性キーがないのか
Gemini API は REST 風のステートレスなインターフェースを採用しています。generateContent の呼び出しは概念上「副作用を持たない」と見なされており、サーバー側で「同じリクエストが二回来たら一回分の生成しか課金しない」といった重複排除は提供されません。これは LLM の応答が確率的であるため、二回目の呼び出しが一回目と異なる結果を返すのが正常な挙動だからです。
しかし、運用者の側から見ると話は別です。記事自動生成パイプラインのように「ある日付・ある題材で 1本だけ生成する」要件では、リトライによる重複生成は許容できません。私の場合、最初の事故では Cloudflare Workers のタイムアウト直前にレスポンスが届き、Workers 側はリトライキューに入れ、結果として同じ題材で 2本生成された記事が同時に push されるところまで進みました。さいわい push 直前の整合性チェックで止まりましたが、そこで止まらなければ GSC 上で重複コンテンツ判定を受けていた可能性があります。
公式機能がない以上、クライアント側で冪等性を担保する責任は私たちにあります。実装の選択肢は大きく3つに分かれます。第一はリクエストハッシュをキーにして「最近のリクエスト」を覚えておく方式、第二はクライアントが UUID を発行してサーバーレス KV に書き込む方式、第三は完了済みジョブのリストを別途持つジョブキュー方式です。私が最終的に採用したのは、これら3つを段階的に組み合わせるハイブリッド設計でした。
冪等性キーの基本設計 — SHA-256 ハッシュとリトライウィンドウ
最初に決めるべきは「何をもって同じリクエストとみなすか」です。完全に同じバイト列のリクエストだけを同一視するのは、現実には弱すぎます。タイムスタンプや乱数シードが入ると同じ題材でもキーが変わってしまいます。逆に題材文字列だけをキーにすると、意図的にバージョン違いを作りたい時に困ります。
私の設計では、リクエストペイロードのうち「意味のある部分」だけを正規化してハッシュ化し、それを「リトライウィンドウ」(短い時間枠)と組み合わせます。具体的には以下の TypeScript コードで生成します。
// idempotency-key.ts
import { createHash } from "node:crypto" ;
export interface IdempotencyInput {
// 意味のあるリクエスト本体
model : string ;
systemPrompt : string ;
userPrompt : string ;
responseSchema ?: object ;
// 運用上のスコープ
site : string ; // "gemini-lab" | "claude-lab" | ...
pipeline : string ; // "daily-content" | "premium-thu" | ...
// リトライウィンドウ(5分単位に丸める)
windowMinutes ?: number ;
}
export function buildIdempotencyKey ( input : IdempotencyInput ) : string {
const windowMinutes = input.windowMinutes ?? 5 ;
const nowMs = Date. now ();
const windowStart = Math. floor (nowMs / (windowMinutes * 60_000 )) * (windowMinutes * 60_000 );
// 正規化: キーの順序を固定、空白を統一
const canonical = JSON . stringify ({
m: input.model,
sp: input.systemPrompt. trim (),
up: input.userPrompt. trim (),
rs: input.responseSchema ?? null ,
site: input.site,
pipeline: input.pipeline,
w: windowStart,
});
const hash = createHash ( "sha256" ). update (canonical). digest ( "hex" );
// KV キーのプレフィックスをつけて、site:pipeline:hash の階層構造にする
return `idem:${ input . site }:${ input . pipeline }:${ hash . slice ( 0 , 32 ) }` ;
}
ハッシュを 32 文字に短縮しているのは KV のキーサイズ制限(512 バイト)と命名規約の可読性を両立させるためです。SHA-256 は 64 文字ですが、衝突確率を考えると 32 文字(128 ビット)あれば 6サイト・1日数百リクエスト規模では十分に安全です。Birthday Paradox で計算すると、$2^{64}$ 件並ぶ前に衝突は実質ゼロです。
リトライウィンドウ(5分)の意味は重要です。これは「5分以内に同じハッシュのリクエストが来たら同一視する」という宣言で、Gemini API の典型的なタイムアウトとリトライ周期(数十秒)よりも十分に長く、別の日に同じ題材で生成したい時には別キーになる、というバランスを取っています。私は当初 1分にしていましたが、Cloudflare Workers のリトライキューが最大 2〜3分後に再投入することがあると分かって 5分に伸ばしました。
Cloudflare Workers KV をリプレイ検出ストアにする TTL 設計
ハッシュキーを決めたら、それを記録しておく場所が必要です。私は Cloudflare Workers KV を採用しています。理由は単純で、Workers から低遅延でアクセスできる、グローバルに伝播する、コストが安い(無料枠で十分)、TTL を秒単位で指定できるからです。Redis や DynamoDB でも実現できますが、6サイト並行運用の規模では KV で十分でした。
// dedup-store.ts
import type { KVNamespace } from "@cloudflare/workers-types" ;
const TTL_SECONDS = 30 * 60 ; // 30 分
export type DedupResult =
| { state : "fresh" ; key : string }
| { state : "duplicate" ; key : string ; firstSeenMs : number }
| { state : "kv_error" ; key : string ; error : Error };
export async function checkAndMark (
kv : KVNamespace ,
key : string
) : Promise < DedupResult > {
try {
const existing = await kv. get (key, "json" ) as { firstSeenMs : number } | null ;
if (existing) {
return { state: "duplicate" , key, firstSeenMs: existing.firstSeenMs };
}
// SETNX 相当の動作: put は最後勝ちなので、ここに2つ同時に来ると両方 fresh と判定する
// 並行リクエストを完全には防げないが、実運用では「ほぼ同時」の頻度が小さい
await kv. put (
key,
JSON . stringify ({ firstSeenMs: Date. now () }),
{ expirationTtl: TTL_SECONDS }
);
return { state: "fresh" , key };
} catch (err) {
return { state: "kv_error" , key, error: err as Error };
}
}
TTL を 30分にしているのは、リトライウィンドウ(5分)の 6倍を確保することで「TTL ぎりぎりで消えた直後にリトライが来る」という稀ケースを潰すためです。これは経験的な数値で、最初は 15分でしたが、Cloudflare のリトライ動作と DNS キャッシュの組み合わせで稀に 18〜20分遅れてリトライが届くケースを観測してから 30分に伸ばしました。
注意点として、KV の put は SETNX セマンティクスを持ちません。同じキーへの並行 put は両方とも成功し、片方が上書きされます。完全な排他制御が必要な場合は Durable Objects を使う必要がありますが、私の運用では「ほぼ同時」のリクエストが現実にはほぼ起きないため、KV のこの挙動で十分です。実観測でも 5ヶ月で並行 put による失敗は 0件でした。
リトライ嵐が起きる代表的な 3 つのシナリオ
冪等性キーが本当に効くのか確認するために、過去 5ヶ月のログから「リトライによる重複候補」を抽出してみました。重複と判定されたケース(28件)の内訳は以下の通りです。
ストリーミング途中での切断(11件、39%) — Gemini API の streamGenerateContent で、クライアント側がレスポンスをほぼ受け取り終わった直後に TCP RST を受けるケース。クライアントは「失敗」と判定してリトライしますが、サーバー側ではトークン課金が完了しています。冪等性キーで検出できれば、課金分は無駄になりますが、二重生成は防げます。
429 後の指数バックオフ(9件、32%) — レートリミット時の自動リトライ。Cloudflare Workers のキュー機能を併用していると、Workers 自身のリトライと、内部の SDK が持つリトライが二重に走ることがあります。私の場合は SDK 側のリトライを無効化し、Workers のキュー側だけに任せる方針に変更してこの数字を 32% から 12% まで下げました。
Workers のリトライキュー(8件、29%) — Cloudflare Workers の event.waitUntil で起動した非同期ジョブがタイムアウト直前で打ち切られ、後段のキューに再投入される。実は元のジョブはレスポンス送信寸前まで進んでいたケースが多く、ここで冪等性キーが重複を検出してくれます。
5ヶ月間の総リクエスト数は約 18,400 件、うち重複として検出されたのが 28 件、すり抜けて二重生成に至ったのは 0 件でした。重複検出率は 0.15% で、ノイズではなく明確に意味のある数値です。実運用上のコスト感覚としては、Gemini 2.5 Pro の長文生成(プレミアム記事 1本)が約 35 円かかるので、28回 × 35円 = 980円分の課金浪費を 5ヶ月間で防いだことになります。投じている KV のコストは月次でゼロ円(無料枠内)なので、純粋に効いている設計と言えます。
KV が読めない時・書けない時のフォールバック設計
ここが設計で最も悩んだ部分です。KV が一時的に応答しない時、checkAndMark は kv_error を返します。この時にどう振る舞うかで運用上の評価が分かれます。選択肢は3つあります。
選択肢 A は「KV が読めなくても処理を続行する(fail-open)」。可用性を優先する設計ですが、その瞬間にリトライが起きていた場合、重複生成を許してしまいます。私の場合は 1本あたり 35円のコストが二重に発生するうえ、GSC 上で重複コンテンツの懸念があるので採用しませんでした。
選択肢 B は「KV が読めなければ処理を停止する(fail-closed / deny-by-default)」。一見すると保守的すぎて運用が止まる印象を持たれがちですが、Gemini API への呼び出しが「すぐにやり直せる」性質を持つ場合は、これが最も安全です。私のパイプラインはスケジュール駆動なので、次の実行時刻まで待てば自動的にリトライされます。実装も単純で、KV エラーをそのまま例外として上位に投げます。
選択肢 C は「KV エラー時にローカルメモリのキャッシュにフォールバック」。Workers のインスタンス内では globalThis 等にキャッシュを持てるので、最後のリクエストだけは記録できます。ただし Cloudflare のインスタンス分散の都合で、異なるエッジロケーションから来たリトライには効きません。私は補助的に実装はしていますが、これだけに頼る設計はお勧めしません。
最終的に採用したのは B + C のハイブリッドです。KV が読めない時はまずローカルキャッシュを参照し、ヒットすれば重複として扱う、ローカルにもなければ「不明」として例外を投げ、上位のジョブキューが自動再試行する、という流れです。「不明な時は止める」という方針は、誤検出よりも見逃しのコストが大きい今回のユースケースで最も合理的でした。私は他のサイトの決済処理でも同じ deny-by-default の原則を採用しており、データ整合性が金銭的な意味を持つ場面では推奨する設計です。
監視とアラートの実装 — Cloudflare Analytics + Slack 通知
冪等性キーは「効いている時には見えない」種類の仕組みです。重複を検出して何もしないと、運用者からは「何も起きていない」と区別がつきません。そのため、私は重複検出のたびに Slack に通知を送るようにしています。通知が増えすぎるとうるさいので、5分間に同じパイプラインで複数回検出されたらまとめて 1通にする、という束ねロジックを入れています。
// alert.ts
async function notifyDuplicate (
result : DedupResult ,
context : { site : string ; pipeline : string ; topic : string }
) {
if (result.state !== "duplicate" ) return ;
const ageMs = Date. now () - result.firstSeenMs;
await fetch (process.env. SLACK_WEBHOOK ! , {
method: "POST" ,
headers: { "content-type" : "application/json" },
body: JSON . stringify ({
text: `🔁 Idempotency hit on ${ context . site }/${ context . pipeline }` ,
blocks: [
{
type: "section" ,
fields: [
{ type: "mrkdwn" , text: `*Site:* ${ context . site }` },
{ type: "mrkdwn" , text: `*Pipeline:* ${ context . pipeline }` },
{ type: "mrkdwn" , text: `*Topic:* ${ context . topic }` },
{ type: "mrkdwn" , text: `*Original seen:* ${ Math . round ( ageMs / 1000 ) }s ago` },
],
},
],
}),
});
}
加えて、Cloudflare Analytics で「KV エラー率」を週次でモニタリングしています。エラー率が 0.1% を超えた週があれば、その週のリトライキューの状態を念のため確認します。5ヶ月で 0.1% を超えた週は 2回ありました。両方とも Cloudflare の地域的な障害と相関していて、私の設計上の問題ではありませんでした。
運用で見えた 4 つの落とし穴
ここまでが基本設計ですが、5ヶ月運用する中で気づいた落とし穴も共有します。どれも公式ドキュメントには書かれていない、実運用で初めて見える挙動です。
第一の落とし穴は、Cloudflare KV のリードキャッシュです。同じキーへの読み込みは Edge ロケーションでキャッシュされるため、別のエッジから書き込まれたばかりの値が即時に見えるとは限りません。実測では数秒の遅延がありました。リトライウィンドウを 5分に伸ばした理由の一つはこれです。
第二の落とし穴は、本番とテスト環境で KV ネームスペースを分離しないと、ローカル開発時のリクエストが本番の重複検出を汚染することです。私は最初 1つのネームスペースを共用していて、ローカルで何度かテストした直後に本番の自動生成が「重複」として全部スキップされる事故を起こしました。それ以来、KV_DEDUP_DEV と KV_DEDUP_PROD を完全に分け、wrangler.toml の [env.production] でバインディングを切り替えています。
第三の落とし穴は、ハッシュ計算の入力に環境変数を含めると、本番と staging で別キーになる点です。これは利点でもあり欠点でもあります。staging で動作確認したジョブが本番でリトライされないので、staging の動作確認は本番の冪等性検出を強化する効果があります。
第四の落とし穴は、responseSchema をハッシュ入力に含めるかどうかです。私はスキーマも入力に含めていますが、スキーマを微修正した直後のジョブが「別リクエスト」として扱われ、リトライ嵐の最中に新旧両バージョンが共存することがあります。私の場合はスキーマ変更は計画的にやるので問題になりませんでしたが、頻繁にスキーマを変える場合はバージョン番号で抽象化しておくのをお勧めします。
次のステップ — 規模別の推奨実装
最後に、これから冪等性キーを導入する読者向けに、規模別の推奨を整理しておきます。
個人開発者の規模(1日数十〜数百リクエスト)であれば、SHA-256 + KV だけで十分です。Durable Objects まで持ち出す必要はありません。私の 6サイト並行運用がまさにこの規模で、5ヶ月で 0件の重複生成を達成しています。
SaaS として複数テナントを抱える規模(1日数千〜数万リクエスト)であれば、KV のキー設計を idem:{tenant}:{pipeline}:{hash} のように階層化し、テナントごとに別ネームスペースに分けるのを推奨します。テナント間で重複検出が混線しないようにするためです。
エンタープライズ規模(1日数十万リクエスト以上)であれば、KV の代わりに Durable Objects か外部の Redis / DynamoDB を採用し、SETNX セマンティクスで並行 put の競合を厳密に解決することをお勧めします。この規模になると、コストよりも整合性のほうが重要になります。
冪等性キーは目立たない仕組みですが、本番運用の安心感を 2 段くらい底上げしてくれます。私自身、これを導入してから「リトライをためらわなくなった」という変化が一番大きかったと感じます。リトライが怖くないと、ネットワーク不安定時の挙動を強気に設計できるようになり、結果としてユーザー体験が安定します。同じように複数サービスを並行運用している方の参考になれば幸いです。お読みいただきありがとうございました。