◉GEMINI LABJP
●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…
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 API249TypeScript8Error Handling3Type SafetyProduction33

✦ 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-03-30
Firebase Genkit × Gemini API in Production — Design Choices and Gotchas from a Month of Indie Backend Work
Field notes from a month of running Firebase Genkit and Gemini API behind an indie app review-summarization backend. Flow and Tool design, Cloud Functions vs. Cloud Run, real cost and latency numbers, and seven undocumented gotchas.
⟐ Dev Tools2026-09-10
Matching name variants in a print catalog: normalize first, then ask Gemini for candidates
When the same artist appears under three spellings, asking Gemini whether two names are the same person quietly merges different generations. Here is the two-layer alternative — deterministic normalization first, model-generated candidates second — with working code and a cost estimate.
⟐ Dev Tools2026-09-06
Icon-Only Buttons Can Stay Silent for Screen Readers, Even When Every Translation Is in Place
A fully translated app can still read as silence. Here is how I collect icon elements and labels from iOS and Android, settle everything mechanical locally, and send only the wording to Gemini — with the actual output from each stage.