GEMINI LABJP
FLASH — Gemini 3.5 Flash is now generally available and powers gemini-flash-latestAGENTS — Managed Agents in the Gemini API enter public preview, running autonomously in Google-hosted isolated Linux sandboxesSTUDIO — Google AI Studio adds project-level spend caps and native Android vibe codingLIVE — Gemini 3.1 Flash Live Preview, an audio-to-audio model for real-time dialogue, is now availableIMAGE — Nano Banana 2 Lite arrives as the fastest, most cost-efficient image model, and Gemini Omni Flash is in API previewLYRIA — Lyria 3 music generation models (clip-preview and pro-preview) are now availableFLASH — Gemini 3.5 Flash is now generally available and powers gemini-flash-latestAGENTS — Managed Agents in the Gemini API enter public preview, running autonomously in Google-hosted isolated Linux sandboxesSTUDIO — Google AI Studio adds project-level spend caps and native Android vibe codingLIVE — Gemini 3.1 Flash Live Preview, an audio-to-audio model for real-time dialogue, is now availableIMAGE — Nano Banana 2 Lite arrives as the fastest, most cost-efficient image model, and Gemini Omni Flash is in API previewLYRIA — Lyria 3 music generation models (clip-preview and pro-preview) are now available
Articles/Dev Tools
Dev Tools/2026-07-09Advanced

Closing the Failures That Never Throw: Normalizing Gemini API Responses into a Discriminated Union

An HTTP 200 with an empty body will never reach your catch block. Here is how I normalize finishReason and blockReason into a discriminated union, and let a never check turn missed cases into compile errors.

Gemini API176TypeScript8Error HandlingType SafetyProduction32

Premium Article

At 2 a.m. my wallpaper app's description-generation batch reported "1,842 succeeded" and exited cleanly. The next morning, about twenty documents in Firestore held nothing but an empty string.

Nothing threw. Every HTTP status was 200. The SDK never complained. response.text was simply the empty string.

The code I had written looked like this.

try {
  const res = await ai.models.generateContent({ model, contents });
  await save(res.text ?? "");   // quietly persists ""
} catch (e) {
  logger.error(e);
}

A catch block only sees failures that were thrown. Most Gemini API failures are never thrown — they arrive wearing the shape of a successful response. This is the record of how I closed that gap with the type system.

Gemini failures live in three layers

Once I sorted them by where they originate, the picture became clear.

LayerExamplesHow you catch it
TransportTimeouts, DNS failures, dropped connectionsThrown
HTTP400 INVALID_ARGUMENT, 429, 500, 503Thrown
SemanticEmpty candidates, finishReason SAFETY / MAX_TOKENS / RECITATIONNever thrown. You must look

The first two layers are well covered — I wrote about telling retryable 429s apart from spend-cap exhaustion in a separate piece on 429 handling. The third layer is where the silence lives.

To the SDK, a semantic failure is a success. The response gets assembled, the reason gets written into promptFeedback or candidates[0].finishReason, and it comes back. If the caller never reads those fields, the failure effectively never happened.

That is exactly how I ended up persisting empty strings.

Enumerate every possible outcome first

Before writing any types, I listed the shapes a response can actually take. For my workloads, everything fit into five branches.

  1. promptFeedback.blockReason is set — the input was stopped, and candidates does not exist.
  2. candidates is an empty array — the model returned nothing.
  3. finishReason: "STOP" with non-empty text — the only real success.
  4. finishReason is "MAX_TOKENS", "SAFETY", "RECITATION", or "OTHER" — generation stopped on the output side.
  5. finishReason: "STOP" but the text is empty — I hit this with structured output when the model could not satisfy the schema.

The fifth case is the nasty one. If you judge success by finishReason alone, an empty string sails straight through. That is precisely the path that produced my twenty broken documents.

So the predicate is not "is finishReason STOP." It is "is finishReason STOP and is the text non-empty." Obvious in hindsight; nowhere in my code at the time.

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
How to enumerate the five failures that arrive as HTTP 200 (empty candidates, SAFETY, MAX_TOKENS, RECITATION, OTHER)
A discriminated union plus a never check that turns a newly added finishReason into a compile error
A retry-eligibility table per failure kind, plus 30 days of measured failure distribution from a live batch
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.

or
Unlock all articles with Membership →
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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Dev Tools2026-05-24
Running Streamlit + Gemini as a Production BI Dashboard — Auth, Cost, Caching, Rate Limits, Observability
A design memo for promoting a Streamlit + Gemini data analysis app into a real multi-user internal BI dashboard — authentication, cost optimization, result caching, per-user rate limits, and observability, all from production experience.
Dev Tools2026-04-04
Gemini API × SaaS Revenue Blueprint 2026 — Architecture, Implementation, and Growth from Zero
A complete blueprint for building a revenue-generating AI SaaS on Gemini API. Covers architecture, TypeScript implementation, Stripe billing, Cloudflare Workers deployment, cost optimization, and user acquisition — with production-ready code throughout.
Dev Tools2026-03-30
Firebase Genkit × Gemini API in Production — Field Notes from an Indie Developer Running 50M-Download Apps
Production field notes from running Firebase Genkit and Gemini API on the back end of indie wallpaper and mindfulness apps that cumulatively passed 50M downloads. Covers Flow and Tool design, RAG, deployment, real cost and latency numbers, plus seven undocumented gotchas you only find after a month in production.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →