◉GEMINI LABJP
●OMNI 1.2 — Gemini Omni 1.2 Flash is generally available (11/5) with up to 15-second video and conversational editing; 1.1 Flash is routed to 1.2 on 12/3●COMPUTER — Computer Use is now generally available in the Interactions and GenerateContent APIs (10/30)●LIVE — Live and TTS preview models shut down as early as 11/17 (10 days left). Move to gemini-3.8-live and the 3.8 TTS models●CLI — Gemini API CLI (gemini-api) is out in beta (10/27): a zero-dependency client for agents and CI●2.5 PRO — gemini-2.5-pro and 2.5-flash are routed automatically on 12/1 (24 days left), to 3.8 Flash and 3.5 Flash-Lite●NEW — Stop Re-Reading Every Crash Report: Gre…●OMNI 1.2 — Gemini Omni 1.2 Flash is generally available (11/5) with up to 15-second video and conversational editing; 1.1 Flash is routed to 1.2 on 12/3●COMPUTER — Computer Use is now generally available in the Interactions and GenerateContent APIs (10/30)●LIVE — Live and TTS preview models shut down as early as 11/17 (10 days left). Move to gemini-3.8-live and the 3.8 TTS models●CLI — Gemini API CLI (gemini-api) is out in beta (10/27): a zero-dependency client for agents and CI●2.5 PRO — gemini-2.5-pro and 2.5-flash are routed automatically on 12/1 (24 days left), to 3.8 Flash and 3.5 Flash-Lite●NEW — Stop Re-Reading Every Crash Report: Gre…
Articles/API / SDK
◈ API / SDK/2026-08-08Advanced

A Timeout Was Never Evidence of Failure — Designing Around Blocking Function Calls

When a tool call cannot return until the real-world effect finishes, two habits reverse on you: parallel dispatch and generous timeouts. Measured numbers from a sandbox harness, plus the design that replaces retries with observation.

Gemini API255Function Calling17Agent DesignTimeoutsIdempotency3

✦ Premium Article

I was reading Google's robotics documentation when one detail stopped me. The agent loop runs every function call the model returns, and only then sends the results back to the model. A robot finishes the move before the next instruction goes out.

Nothing to do with me, I thought. I own no robot arms. But the sentence would not let go.

Plenty of my own automation has to wait until the thing is actually done.

Submitting a build to App Store Connect. Batch-converting wallpaper assets. Verifying a purchase receipt. None of them finish at the moment you call them, and while the caller waits, the outside world keeps changing. Structurally, that is the same problem as a moving arm.

So I built a harness and measured it. The short version: the two habits I had trusted most — parallel dispatch and comfortably generous timeouts — both worked against me for this class of tool.

Tools that cannot return early are everywhere

"Blocking behavior" is robotics vocabulary, but the conditions behind it are ordinary once you abstract them.

ConditionWhat it meansExample from indie development
The effect lives outsideThe point is the external state change, not the return valueBuild submission, email delivery, payment capture
It cannot be recalledOnce started, the caller has no way to stop itAsset conversion jobs, push notification fan-out
It cannot overlapTwo of them must never touch the same resource at onceWriting the same file, reserving the same inventory

Any tool matching two or more of these, I now tag as a blocking tool and treat separately. Its design assumptions have nothing in common with an ordinary tool — search, summarize, look up.

An ordinary tool that fails can simply be called again. For a blocking tool, calling it again is itself the incident.

Reproducing it locally with a minimal harness

I wanted this structure isolated, without a robot or a cloud bill. The harness writes to a file instead of moving anything, and counts how often two supposedly exclusive intervals overlap.

import asyncio, time
 
class Actuator:
    """Stands in for a physical action: observably busy from start to finish."""
    def __init__(self):
        self.busy = False
        self.overlaps = 0   # times two exclusive intervals overlapped
        self.effects = 0    # effects that actually landed in the outside world
 
    async def move(self, action_id: str, dur: float) -> None:
        if self.busy:
            self.overlaps += 1
        self.busy = True
        try:
            await asyncio.sleep(dur)          # the real motion / external job
            self.effects += 1
            with open("effects.log", "a") as f:
                f.write(action_id + "\n")
        finally:
            self.busy = False
 
 
async def blocking_call(act: Actuator, action_id: str, dur: float, timeout: float | None = None) -> str:
    """A tool call. The timeout only ends our waiting; it does not stop the action."""
    task = asyncio.create_task(act.move(action_id, dur))
    if timeout is None:
        await task
        return "ok"
    try:
        # shield is the whole point. Plain wait_for cancels the task it waits on,
        # but real physical actions and external jobs cannot be cancelled.
        await asyncio.wait_for(asyncio.shield(task), timeout)
        return "ok"
    except asyncio.TimeoutError:
        return "timeout"    # the call returns; the action continues

That asyncio.shield line matters more than it looks. Written naively, asyncio.wait_for cancels the task it is waiting on — which would simulate a world where giving up on the wait also stops the work.

A real arm keeps moving after you stop watching. So does a payment capture, and so does a build upload. Shield is how I made the code honest about that asymmetry.

This was also the trap I fell into first. Measured without shield, the harness reports zero duplicate executions across the board — which looks like proof that the design is safe.

All it proves is that the test obeys cancellation. In production, aborting an HTTP request does not stop the server from finishing the work, and giving up on a subprocess does not stop the process. Cancellation only ever applied to the local await.

So my recommendation, whenever you touch a blocking tool, is to suspect your own abstraction before you trust the result. When the numbers look good from the very first run, the model is usually the thing that is too forgiving.

✦

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
✦Across three timeout thresholds, 97-100% of fired timeouts turned out to be actions that had already succeeded
✦Dispatching four resource-sharing tools in parallel cut wall clock from 1203ms to 301ms — and produced 3 forbidden overlaps
✦Swapping retry for observation took duplicate executions from 30 to 0, at a median cost of 67ms of extra waiting
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

◈ API / SDK2026-10-30
An 'API key expired' Error Doesn't Always Mean Expired. Check Which Key Is Actually Sent Before You Renew It
A 400 with API_KEY_INVALID from the Gemini API cannot tell you whether the key expired or something else is wrong. A small diagnostic script checks env-var precedence and stray quotes first, run here against dummy keys.
◈ API / SDK2026-10-30
My ADK Agent Never Ran and Said 'API key expired' — Reading the Reason Code Gave Me a Triage Order
When ADK returns 400 INVALID_ARGUMENT 'API key expired. Please renew the API key.' on every run, check this order before assuming the key expired: read the reason code, compare shell and .env keys by their last four characters, then test the key alone over REST.
◈ API / SDK2026-10-27
New paid projects now start with request storage switched on. Three places to check the day you notice
Gemini API paid projects created on or after October 26, 2026 store generateContent requests by default. How to tell whether your project is affected, how to turn storage off in Logs Settings, how to set store=False per request, and how to shorten retention, following the official wording.