◉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/Advanced
⬡ Advanced/2026-07-03Advanced

Your Night Batch Is Causing the Morning 429s — Priority Admission Control for a Shared Gemini Quota

When bulk jobs and interactive features share one project's RPM/TPM, the bulk lane wins by default. A priority token bucket design with measurements: 429 rate 3.2% down to 0.03%.

Gemini API249rate limitsarchitecture16token bucketoperations19production140

✦ Premium Article

Around 8 a.m., our user-facing generation feature started throwing bursts of 429s — and only that feature. I noticed the pattern three days after I began running a nightly job that regenerates localized app descriptions in bulk. The job was scheduled for 2 a.m., but as the item count grew, its tail crept into the morning, right into the hours when real users show up. As an indie developer I tend to run several features inside a single Google Cloud project, and this shape of failure — features fighting each other for one quota — is something you will hit sooner or later if you do the same.

Gemini API rate limits (RPM and TPM) apply per model, per project. As long as everything calls from the same project, the chat feature a user is actively waiting on and the batch job nobody is waiting on draw from the same bucket. This article records how I turned that contract into a design that protects the interactive lane, with the measurements before and after.

First, Figure Out Who Actually Emptied the Bucket

429 discussions usually jump straight to retry strategy, but retries assume the quota will recover. When another feature is continuously consuming it, retries just lengthen the queue — and retry amplification makes the consumption worse. I covered how to classify retryable versus non-retryable 429s in my piece on 429 retry design by root cause; this time we start one step earlier: attribution.

The first thing I did was force every Gemini call through a thin wrapper that requires a feature tag.

import time
import threading
from collections import defaultdict, deque
from google import genai
 
client = genai.Client()
 
class TaggedGeminiClient:
    """Force a feature tag on every call and record per-minute usage."""
 
    def __init__(self, client: genai.Client):
        self._client = client
        self._lock = threading.Lock()
        # feature -> deque[(epoch_minute, requests, tokens)]
        self._usage = defaultdict(lambda: deque(maxlen=180))
 
    def generate(self, *, feature: str, model: str, contents, config=None):
        resp = self._client.models.generate_content(
            model=model, contents=contents, config=config
        )
        used = resp.usage_metadata
        total = (used.prompt_token_count or 0) + (used.candidates_token_count or 0)
        minute = int(time.time() // 60)
        with self._lock:
            bucket = self._usage[feature]
            if bucket and bucket[-1][0] == minute:
                m, r, t = bucket[-1]
                bucket[-1] = (m, r + 1, t + total)
            else:
                bucket.append((minute, 1, total))
        return resp
 
    def snapshot(self, last_minutes: int = 60):
        """Per-feature RPM / TPM over the last N minutes."""
        cutoff = int(time.time() // 60) - last_minutes
        out = {}
        with self._lock:
            for feature, buckets in self._usage.items():
                rows = [b for b in buckets if b[0] >= cutoff]
                if rows:
                    out[feature] = {
                        "avg_rpm": sum(r for _, r, _ in rows) / len(rows),
                        "peak_rpm": max(r for _, r, _ in rows),
                        "peak_tpm": max(t for _, _, t in rows),
                    }
        return out

Because it aggregates usage_metadata, you get real token counts for free. After a week of data, the picture was unambiguous: during the 7–9 a.m. peak, the bulk regeneration job owned 82% of RPM. The interactive 429s weren't caused by interactive growth at all — the batch tail had simply reached the morning.

FeatureCall patternPeak RPM shareTolerable delay
Interactive generation (user-facing)Morning/evening spikes14%Seconds — users feel it
Bulk description regenerationStarts at night, runs for hours82%Hours — nobody is waiting
Notification draft generationSporadic4%Minutes

The "tolerable delay" column is the entire design. The problem was never total capacity; it was that workloads with wildly different delay tolerance were drawing from one bucket at equal priority.

Before/After — Putting an Admission Gate in Front of Every Call

Before, each feature called the SDK directly, the obvious way:

# Before: every feature calls whenever it likes
# interactive handler
resp = client.models.generate_content(model="gemini-flash-latest", contents=user_prompt)
 
# bulk worker (loops over thousands of items)
for item in items:
    resp = client.models.generate_content(model="gemini-flash-latest", contents=build_prompt(item))

The flaw is that you only learn about congestion when the API-side rate limiter tells you — and by the time a 429 arrives, the distinction between an interactive request and a bulk request is gone. The fix is to do the traffic sorting on your side, in front of the API, with admission control:

# After: every call passes through a priority-aware gate
gate = PriorityAdmissionGate(rpm_limit=1000, tpm_limit=1_000_000,
                             reserved_interactive_ratio=0.3)
 
# interactive handler
async with gate.acquire(feature="chat", priority="interactive", est_tokens=1200):
    resp = await async_generate(user_prompt)
 
# bulk worker
async with gate.acquire(feature="bulk_regen", priority="bulk", est_tokens=2800):
    resp = await async_generate(build_prompt(item))

The gate's contract has exactly two clauses. A fixed share of capacity (30% here) is always reserved for interactive traffic. Bulk may use everything else — and may borrow the reserved share while interactive is idle — but borrowing never flows the other way.

✦

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
✦Feature-tagged instrumentation that identifies which feature is eating your RPM/TPM, and how to read the result (bulk owned 82% of peak RPM in my case)
✦A working Python priority token bucket that reserves capacity for interactive traffic while letting bulk borrow idle headroom — controlling both RPM and TPM
✦Two weeks of before/after numbers (429 rate 3.2%→0.03%) plus the easy-to-miss operational fact that separate API keys do not isolate quota
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

⬡ Advanced2026-09-14
When BLOCK_NONE Changes Nothing: Telling Adjustable Gemini Blocks from the Ones You Can't Move
Some safety blocks go away when you loosen a threshold. Others never will. Here is how to read the response and decide which kind you are looking at before you touch a single setting.
⬡ Advanced2026-08-06
The Day the Knowledge Cutoff Moved Forward, the Stale Part Was My System Instruction
When a model's knowledge cutoff advances, the thing that goes stale is not the model — it is the dated assertions in your system instruction. Here is why only the lines written between the two cutoffs flip from helpful to contradictory, plus a working audit script and its measured results.
⬡ Advanced2026-09-24
I Stopped Asking Gemini 'Is This Right?' and Started Hiding My Answer Instead
Ask Gemini to confirm a label you already chose and it tends to agree. Here is why I compared three ways of asking, chose blind re-classification, how my answer still leaked in, and the routing code that sends only disagreements to a human.