●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Idempotency Key Design for the Gemini API: Patterns I Use to Prevent Duplicate Generation Across Six Sites
After five months of running six AI-driven sites in parallel, I built an idempotency layer in front of the Gemini API to neutralize retry storms. This deep dive shares the SHA-256 + Cloudflare Workers KV design, the operational numbers behind it, and the four gotchas that only surface in production.
I have been shipping iPhone and Android apps as a solo indie developer since 2014, and after my titles crossed a cumulative 50 million downloads, I started running into duplicate-event bugs everywhere — duplicate purchases, duplicate analytics events, duplicate push notifications. Lately I have been running six sites in parallel — Claude Lab, Gemini Lab, Antigravity Lab, Rork Lab, plus two non-tech blogs called Lacrima and Mystery — and as Gemini API call volume climbed, the same class of bug showed up again. This time it was the article auto-generation pipeline producing the same topic twice and very nearly pushing two MDX files for the same day.
Retries are a good thing. They are the first line of defense against transient network failures, server-side throttling, and timeouts, and you cannot run production without them. But retries are also a duplication factory. Gemini does not offer an Idempotency-Key header, which means clients have to enforce request uniqueness on their own. In this post I share the idempotency layer I have been running for five months across six sites, with the actual code, the actual numbers, and the four gotchas I only discovered in production.
Why the Gemini API Has No Built-in Idempotency
The Gemini API is a stateless, REST-style surface. A generateContent call is conceptually side-effect-free, which is why the server does not deduplicate "the same request arriving twice" for you. That stance makes sense from Google's side: LLM outputs are inherently probabilistic, so a second call returning a different completion is normal behavior, not a bug.
It is a different story from the operator's side. For a pipeline like mine — "generate exactly one article for this topic on this date" — duplicate generation is not acceptable. My first incident happened when a response arrived right before a Cloudflare Workers timeout. Workers requeued the job, the retry succeeded, and I had two complete articles for the same topic queued for push. The pre-push integrity check caught it, but if it had not, Google Search Console would have likely flagged duplicate content within days.
With no server-side mechanism to lean on, the responsibility lands on the client. There are three implementation styles to choose from: hash the request body and remember "recent" requests, generate a client-side UUID and persist it in a serverless KV, or maintain a separate queue of completed jobs. I ended up combining the first two in a hybrid design.
The Core Design — SHA-256 Hash Plus a Rolling Retry Window
The first decision is "what counts as the same request." Comparing raw byte sequences is too strict — a single timestamp or random seed in the payload will produce different keys for what is logically the same call. Comparing just the topic string is too loose, because there are situations where I deliberately want two variants of the same topic.
What I settled on is hashing only the semantically meaningful slice of the payload and combining that with a "retry window" — a short bucket of time. The TypeScript implementation looks like this:
I truncate the hash to 32 hex characters (128 bits) to fit comfortably within the KV key size limit (512 bytes) while keeping the prefix human-readable. With six sites and a few hundred requests per day, the birthday-paradox collision probability at 128 bits is effectively zero — you would need roughly $2^{64}$ keys before the first collision is statistically expected.
The five-minute retry window is the more interesting parameter. It declares "two requests with the same hash within five minutes are the same logical operation," which is comfortably longer than Gemini's typical timeout-and-retry cycle (tens of seconds) but short enough that an intentional regeneration tomorrow will produce a different key. I originally set it to one minute, then bumped it to five after observing that Cloudflare Workers' retry queue can re-inject jobs two to three minutes later under load.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Concrete TypeScript implementation of an idempotency key built from a SHA-256 hash plus a rolling retry window, tuned for serverless production
✦Cloudflare Workers KV TTL design and a deny-by-default fallback path for when the KV store itself becomes unreachable
✦Five months of real production data from six sites in parallel — request volume, duplicate detection rate, false positives, and the rationale behind each threshold
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Using Cloudflare Workers KV as the Replay-Detection Store
Once you have a key, you need somewhere to remember it. I use Cloudflare Workers KV. The reasons are mundane: it is fast from Workers, globally replicated, cheap (free-tier covers my volume), and supports per-key TTL in seconds. Redis and DynamoDB both work as alternatives, but at six-site solo scale, KV is more than enough.
// dedup-store.tsimport type { KVNamespace } from "@cloudflare/workers-types";const TTL_SECONDS = 30 * 60; // 30 minutesexport 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 }; } // Note: KV.put has last-write-wins semantics, not SETNX. // Truly simultaneous puts will both see "fresh", but in practice // the frequency of "exactly simultaneous" is extremely low. 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 }; }}
The 30-minute TTL is six times the retry window. The reason I picked six rather than two or three is empirical: I started with 15 minutes, but every once in a while a retry would surface 18–20 minutes after the original, usually correlated with DNS or routing hiccups. Doubling to 30 minutes eliminated those tail cases without bloating storage.
A subtlety worth calling out: KV's put is not SETNX. Two concurrent writes to the same key both succeed, and one silently overwrites the other. If you need strict exclusion you must use Durable Objects. In my workload, "truly simultaneous" puts are vanishingly rare — over five months of operation I have observed exactly zero cases of double-fresh slips. If you anticipate higher concurrency, plan for Durable Objects from day one.
Three Retry-Storm Patterns I Actually Observed
To validate that the idempotency layer was earning its keep, I went back through five months of logs and classified every event flagged as a duplicate. There were 28 such events. They broke down like this:
Mid-stream disconnection (11 events, 39%).streamGenerateContent calls where the client received nearly the entire response, then got a TCP RST. The client retries, but the server has already billed for the full token count. The idempotency key cannot recover the lost completion, but it does prevent the second generation from going out the door.
Exponential backoff after a 429 (9 events, 32%). Rate-limit-triggered automatic retries. When you combine the Cloudflare Workers retry queue with the Gemini SDK's own internal retry, you can get two retry mechanisms running at once. I disabled the SDK-side retry and let Workers' queue own the retry policy, which dropped this category from 32% to 12% on subsequent reruns of the analysis.
Workers' retry queue (8 events, 29%). A waitUntil task hits the Workers wall-time limit and gets requeued by the platform. Usually the original task was already past the API call, and the idempotency key catches the duplicate at the second attempt.
Across five months I sent roughly 18,400 requests total. 28 were caught as duplicates. Zero slipped through to actually produce a duplicate article. A 0.15% duplicate-detection rate sounds tiny, but at ¥35 per long-form generation for Gemini 2.5 Pro, that is ¥980 of avoided billing waste plus zero duplicate-content incidents in GSC. The KV usage stays inside the free tier, so the net economic case is unambiguous.
What to Do When KV Itself Is Unreachable
This is the design decision I agonized over the longest. When KV is temporarily unavailable, checkAndMark returns kv_error. How you behave at that moment defines the safety profile of the whole system.
Option A is fail-open: proceed with the API call even without a successful idempotency check. This prioritizes availability, but if a retry storm happens to be in progress, you will generate duplicates. At ¥35 per article, twice, plus a GSC duplicate-content risk, this is not a tradeoff I am willing to make.
Option B is fail-closed (deny-by-default): if KV is unreachable, refuse to proceed. This sounds aggressive but it is the safest choice when the underlying API call is easy to retry later. My pipeline is schedule-driven, so the next scheduled run will pick up the work automatically. Implementation is trivial — propagate the error and let the upstream job queue do its thing.
Option C is fail-closed with an in-process memory cache. Workers instances can keep state in globalThis between invocations, so the most recent request can survive transient KV outages. The catch is that Cloudflare distributes Workers across edge locations, so a retry arriving at a different edge will not see the cache. I implement Option C as a supplement, but I never rely on it alone — recommending it as a primary mechanism would be misleading.
What I actually run is B + C. If KV errors, I consult the local cache; on a hit I treat the request as duplicate; on a miss I raise the error and let the scheduler retry later. "When in doubt, stop" is the right default whenever data correctness has a monetary dimension. I use the same deny-by-default principle on my Stripe webhook handlers and recommend it for any pipeline where mistakes cost money.
Observability — Slack Alerts and Weekly KV Error Tracking
An idempotency layer is invisible when it works. A duplicate caught and dropped looks indistinguishable from "nothing happened," which makes it easy to grow over-confident or to never notice when the layer breaks. I send a Slack notification on every duplicate detection, with a debounce so that multiple hits within five minutes for the same pipeline collapse into one message.
In parallel I track KV error rate as a weekly Cloudflare Analytics metric. Any week above 0.1% gets a manual review of the retry queue state. Two weeks out of five months crossed that threshold. Both correlated with regional Cloudflare incidents, not with my code — exactly the kind of signal I want this dashboard to surface.
Four Gotchas That Only Surface in Production
Here are four behaviors I never saw in the docs and only discovered after running this in production.
The first one is read caching at the edge. KV reads are cached per edge location, so a value just written from one region is not immediately visible from another. I have measured a several-second propagation delay in practice. Stretching the retry window from one to five minutes was partly to absorb this delay.
The second one is mixing dev and prod into the same KV namespace. I initially used a single namespace, and after running a handful of local tests I came back to find that scheduled production runs were all being suppressed as "duplicates." Since then I keep KV_DEDUP_DEV and KV_DEDUP_PROD strictly separate and bind them in wrangler.toml under [env.production]. Treat this as a hard rule from day one.
The third one is that hashing environment-dependent variables creates separate keys for staging and production, which is a feature, not a bug. Validation runs in staging do not contaminate the production dedup record. I make sure my staging environment runs the same idempotency logic so that issues surface there first.
The fourth one is whether to include responseSchema in the hash. I do, but this means a schema tweak shipped during a retry storm can produce two parallel idempotency keys for the same logical work. In my workflow schema changes are deliberate, planned events, so this has not bitten me. If you change schemas frequently, abstract the schema behind a version number so the hash is stable across non-substantive edits.
Recommendations by Scale
To wrap up, here is how I would recommend approaching this depending on your scale.
If you are a solo developer running a handful of pipelines (tens to hundreds of requests per day), SHA-256 plus KV is genuinely all you need. Durable Objects are overkill. My six-site setup sits right in this band and has prevented every duplicate so far.
If you are running a multi-tenant SaaS (low thousands to tens of thousands of requests per day), I recommend partitioning your KV keys as idem:{tenant}:{pipeline}:{hash} and using separate namespaces per tenant so noisy tenants do not pollute one another's dedup state.
At enterprise scale (hundreds of thousands of requests per day and up), switch from KV to Durable Objects or to an external Redis/DynamoDB with proper SETNX semantics. At that volume, the cost of stricter consistency is cheaper than the cost of letting concurrent puts race.
Idempotency layers are quiet infrastructure. The biggest behavioral change for me was not the prevented duplicates themselves — it was the confidence to retry aggressively, which in turn lets me design the rest of the system more boldly. If you operate multiple services in parallel and have been hedging on retry policy because you were worried about duplicates, I would encourage you to put this layer in first and then turn the retry knob up. Thanks for reading.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.