●SUNSET — Six days until the image generation models shut down: the imagen-4.0 family and Gemini 3 Image models stop on August 17●MIGRATE — gemini-3.1-flash-image is the recommended replacement, and it means rewriting generate_images calls as generate_content●CHECK — The same prompt will not necessarily produce the same picture after migrating, so secure any images you still need before the cutoff●CLASSROOM — August 17 is also the day Gemini in Classroom arrives on mobile; the web rollout to students of all ages began on August 10●DEPRECATION — The Grok 4.1 family shuts down on August 20, and gemini-robotics-er-1.6-preview on August 31, succeeded by the er-2 models●CHANGELOG — The Gemini API changelog still ends at July 30. The most recent major change remains the GA of Gemini 3.6 Flash and 3.5 Flash-Lite●SUNSET — Six days until the image generation models shut down: the imagen-4.0 family and Gemini 3 Image models stop on August 17●MIGRATE — gemini-3.1-flash-image is the recommended replacement, and it means rewriting generate_images calls as generate_content●CHECK — The same prompt will not necessarily produce the same picture after migrating, so secure any images you still need before the cutoff●CLASSROOM — August 17 is also the day Gemini in Classroom arrives on mobile; the web rollout to students of all ages began on August 10●DEPRECATION — The Grok 4.1 family shuts down on August 20, and gemini-robotics-er-1.6-preview on August 31, succeeded by the er-2 models●CHANGELOG — The Gemini API changelog still ends at July 30. The most recent major change remains the GA of Gemini 3.6 Flash and 3.5 Flash-Lite
Classify Gemini API Errors by Status — Handling 429, SAFETY, and Token Limits in Production
Sort Gemini API failures along two axes — HTTP status and finish_reason — so you know instantly whether to wait or to fix. Covers the three distinct 429 limits, model-deprecation fallbacks, and how the exception hierarchy changes after moving to the google-genai SDK.
On the first day I wired the Gemini API into one of my indie apps, what cost me the most time wasn't the error codes themselves — it was deciding whether a given failure would clear up if I waited, or whether I had to go fix my code. A 429 tells you something is wrong, but not whether it's a rate limit or an auth problem until you read the status in the response.
This walkthrough organizes the errors you'll hit by their status and finish_reason, then turns them into handling that won't stall in production — with the code I actually use. The goal isn't a list of errors; it's being able to act without hesitation the moment one shows up.
The First Ninety Seconds
When something fails, the first thing I add isn't a fix — it's a single line of output. Look at the exception type and the code carried in the response before anything else. Skip that and start with "just retry it," and you end up with code that patiently waits on failures that will never clear.
except Exception as e: # Print the type and code first. Everything else branches from here. print(type(e).__name__, getattr(e, "code", None), getattr(e, "message", e))
There are only two axes worth looking at.
HTTP status (400 / 401 / 403 / 429 / 500-503) — whether the request was accepted at all
The first is a problem with how you're sending; the second is a problem with what you're sending. Users report both as "nothing came back," but the fixes have nothing in common. The rest of this walkthrough follows those two axes, status family by status family.
400 Bad Request — Invalid Requests and Wrong Model Names
A 400 error means the API can't understand your request. The three most common causes are model name mistakes, malformed JSON, and input that's too long.
1. Using the wrong model name
Typos or outdated model names are the number-one cause of 400 errors. The Gemini API only accepts specific model names.
import google.generativeai as genaigenai.configure(api_key="YOUR_GEMINI_API_KEY")# ❌ Wrong: Model doesn't existmodel = genai.GenerativeModel("gemini-invalid-model")# ✅ Correct: Use a real model namemodel = genai.GenerativeModel("gemini-2.5-flash")response = model.generate_content("Hello, world!")print(response.text)
gemini-2.5-flash — Fast and inexpensive; fine for everything you do while iterating
gemini-2.5-pro — Higher quality, for long summaries and harder reasoning
gemini-3-pro — Latest generation; availability varies by region
2. Malformed JSON in your request
When using TypeScript or JavaScript, if your request body's JSON structure isn't correct, you'll get a 400 error. Make sure all required fields are present.
Pay special attention to required fields like contents, parts, and role. Missing any of these will cause a 400 error.
3. Input text exceeds maximum length
Very long text inputs can trigger a 400 error. Each Gemini model has a context window limit (the maximum amount of text you can send):
gemini-2.5-flash — Up to 1 million tokens
If you need to process large amounts of text, split it into smaller chunks and make multiple requests.
✦
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
✦A single error handler that classifies exceptions by status and finish_reason to decide retry vs. fail
✦Splitting 429s across RPM, RPD, and TPM, with jittered exponential backoff to avoid retry storms
✦Turning empty responses, SAFETY, RECITATION, and context overflow into cause-specific fixes
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.
You'll need to enable the Vertex AI API in your GCP project. Check the Vertex AI documentation for detailed setup instructions.
429 Too Many Requests — Rate Limiting
You'll get a 429 error if you send too many requests in a short time. The Gemini API has different rate limits depending on whether you're using the free tier or a paid plan.
Free tier rate limits
When using Google AI Studio for free:
Maximum 60 requests per minute
Daily request limits vary
Paid tier rate limits
With a Google Cloud paid plan:
Maximum 360 requests per minute (standard)
Varies by plan and model
Exponential backoff strategy
When you hit a 429 error, don't immediately retry. Instead, use exponential backoff — gradually increasing wait times between retries.
import timeimport google.generativeai as genaigenai.configure(api_key="YOUR_GEMINI_API_KEY")model = genai.GenerativeModel("gemini-2.5-flash")def generate_with_retry(prompt, max_retries=5): """Retry logic with exponential backoff""" for attempt in range(max_retries): try: response = model.generate_content(prompt) return response.text except Exception as e: if "429" in str(e): # Wait: 1s, 2s, 4s, 8s, 16s... wait_time = 2 ** attempt print(f"Rate limited. Waiting {wait_time} seconds...") time.sleep(wait_time) else: raise# Usageresult = generate_with_retry("What is the capital of France?")print(result)
Here's the TypeScript equivalent:
import { GoogleGenerativeAI } from "@google/generative-ai";const genAI = new GoogleGenerativeAI("YOUR_GEMINI_API_KEY");const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });async function generateWithRetry(prompt: string, maxRetries = 5): Promise<string> { for (let attempt = 0; attempt < maxRetries; attempt++) { try { const response = await model.generateContent(prompt); return response.response.text(); } catch (error) { if (error instanceof Error && error.message.includes("429")) { const waitTime = Math.pow(2, attempt) * 1000; // Convert to milliseconds console.log(`Rate limited. Waiting ${waitTime / 1000} seconds...`); await new Promise((resolve) => setTimeout(resolve, waitTime)); } else { throw error; } } } throw new Error("Max retries exceeded");}// Usageconst result = await generateWithRetry("What is the capital of France?");console.log(result);
Which Limit Are You Hitting — RPM, RPD, or TPM?
A 429 doesn't have a single cause. Gemini API enforces three independent limits:
RPM (Requests Per Minute) — how many requests you send each minute
RPD (Requests Per Day) — total requests per day
TPM (Tokens Per Minute) — tokens processed per minute
As an indie developer, the one that caught me first was TPM. Even with few requests, sending long prompts back to back hits the per-minute token ceiling before the request count does. Working out which limit you've reached is the starting point for any fix.
Is RESOURCE_EXHAUSTED Really a Quota Problem?
A 429 response carries RESOURCE_EXHAUSTED in its body:
{ "error": { "code": 429, "message": "Resource has been exhausted (e.g. check quota).", "status": "RESOURCE_EXHAUSTED" }}
RESOURCE_EXHAUSTED means you've hit a quota, and waiting will help. By contrast, INVALID_ARGUMENT and PERMISSION_DENIED are request-format or authentication problems — waiting won't fix them. Check the status first to decide whether to wait or to fix the request.
How Different Are Free and Paid Quotas?
The free tier is meant for development and testing, not production. Rough guidance follows (exact numbers vary by model, region, and over time, so confirm the latest in the official docs):
Free tier — RPM in the tens per minute, RPD in the low thousands per day, TPM around 10,000 tokens, about 2 concurrent sessions
Paid (pay-as-you-go) — RPM up to several thousand per minute, effectively no RPD cap, TPM from 100,000 to 1,000,000 tokens, around 100 concurrent sessions
The easy thing to miss: these limits are shared per project, not per user. If a test script and your production app hit the API under the same project, they add up against the same ceiling. In my own app I once left a test batch running and watched production start returning 429s — it took a while to trace.
As a rough scale guide: under ~10k requests/month, the free tier plus a spend limit is fine; up to ~1M, pay-as-you-go; beyond that, talk to Google Cloud about volume pricing.
Checking and Raising Your Quota
You can see current usage in the Google Cloud Console:
In Google Cloud Console, open "APIs & Services", select Generative Language API, and check "Quotas & System Limits"
Add billing details under "Billing" to switch to paid limits automatically (it can take minutes to hours to take effect)
Avoiding Surprise Bills
Before moving to paid limits, set up a budget alert. Under "Billing" then "Budgets" in the Google Cloud Console, set a monthly cap and enable an alert (for example, notify at 100% of budget). Stopping calls when you hit the cap protects you from unexpected charges.
Watching Your Usage
In production, noticing a limit after you've hit it is too late. Monitor quota_used_count in Cloud Monitoring and alert when usage crosses 80%, so you can decide on a paid upgrade or a retry strategy ahead of time.
displayName: Gemini API quota alertconditions: - displayName: RPM usage > 80% conditionThreshold: filter: | resource.type="api" AND metric.type="serviceruntime.googleapis.com/api/consumer/quota_used_count" AND resource.service="generativelanguage.googleapis.com" threshold_value: 0.8 comparison: COMPARISON_GT
500/503 Server Errors — Google's Infrastructure Issues
When you get a 500 (Internal Server Error) or 503 (Service Unavailable) error, Google's servers are experiencing temporary issues. This isn't a problem with your code or credentials.
If the problem persists, contact Google Cloud Support
import timedef generate_with_500_retry(prompt, max_retries=3): """Retry logic for server errors""" for attempt in range(max_retries): try: response = model.generate_content(prompt) return response.text except Exception as e: error_code = str(e) if "500" in error_code or "503" in error_code: wait_time = 5 * (attempt + 1) # 5s, 10s, 15s print(f"Server error. Retrying in {wait_time} seconds...") time.sleep(wait_time) else: raise
Context Window Overflow — Handling INVALID_ARGUMENT and Token Limits
When you pass a long document or several images at once, you may hit a 400-class error like this:
google.api_core.exceptions.InvalidArgument: 400 Request payload size exceeds the limit
With multimodal requests, it can surface as:
Invalid request: the total input token count exceeds the model's limit
It happens when your input exceeds the model's context window. Rather than reacting after the fact, count your tokens before sending.
import google.generativeai as genaiimport osgenai.configure(api_key=os.environ["GOOGLE_API_KEY"])model = genai.GenerativeModel("gemini-2.5-flash")def count_tokens(prompt: str) -> int: # Check the token count before sending return model.count_tokens(prompt).total_tokensdef safe_generate(prompt: str, max_chars: int = 30000) -> str: if len(prompt) > max_chars: # Trimming the middle preserves more context than cutting the tail half = max_chars // 2 prompt = prompt[:half] + "\n...[truncated]...\n" + prompt[-half:] return model.generate_content(prompt).text
Cutting only the tail tends to lose your conclusion. Keeping the head and tail while dropping the middle leaves both your instructions and your closing intact.
When you truly need the whole thing, switch to the 1M-token context of Gemini 2.5 Pro or Flash, or split the document into meaningful chunks and feed them in sequence.
Empty or Blocked Responses — Diagnosing with finish_reason
No obvious error, yet no text comes back. This is a surprisingly common stumbling point.
response = model.generate_content("...")print(response.text) # AttributeError: 'NoneType' object has no attribute 'text'
Instead of reaching straight for response.text, check whether candidates exists and inspect finish_reason first — the fix depends on the cause.
def safe_get_text(response) -> str | None: # No candidates means the input was blocked if not response.candidates: print(f"Input was blocked: {response.prompt_feedback}") return None candidate = response.candidates[0] reason = candidate.finish_reason.name if reason == "SAFETY": print(f"Blocked by safety filter: {candidate.safety_ratings}") return None if reason == "RECITATION": print("Blocked due to recitation of copyrighted content") return None if reason == "MAX_TOKENS": # Partial text is still available print("Reached max_output_tokens. Raise the limit.") try: return candidate.content.parts[0].text except (IndexError, AttributeError): return None
For SAFETY, rephrase the prompt in more neutral terms. RECITATION appears when the model is asked to reproduce existing text verbatim, so request a summary or paraphrase instead. MAX_TOKENS means raising max_output_tokens. Once each cause maps to a fix, empty responses stop being a mystery.
You can tune filter sensitivity through safety_settings, but in most cases simply revising the wording of your prompt resolves it. Start by reviewing the content of your input prompt.
Classifying Every Exception in One Place
Scattering try/except blocks across each call site means coverage gaps grow as the codebase grows. Once I started running recovery batches daily, I settled on routing every API call through a single wrapper that branches on status and finish_reason in one place.
The idea is simple. Sort exceptions into three groups: "will recover if you wait" (retry), "won't recover until you fix it" (fail fast), and "partially usable" (warn and continue).
import timeimport randomimport google.generativeai as genaifrom google.api_core import exceptions as gaxgenai.configure(api_key="YOUR_GEMINI_API_KEY")model = genai.GenerativeModel("gemini-2.5-flash")# States likely to recover if you waitRETRYABLE = (gax.ResourceExhausted, gax.ServiceUnavailable, gax.InternalServerError)# States that won't recover until you fix the requestFATAL = (gax.InvalidArgument, gax.PermissionDenied, gax.Unauthenticated)def call_gemini(prompt, max_retries=5, base=1.0): for attempt in range(max_retries): try: return model.generate_content(prompt) except RETRYABLE as e: # Exponential backoff + jitter (avoids synchronized retries) wait = base * (2 ** attempt) + random.uniform(0, base) print(f"[retry {attempt + 1}] {type(e).__name__}: waiting {wait:.1f}s") time.sleep(wait) except FATAL as e: # Waiting here is wasted time. Return to the caller immediately. raise RuntimeError(f"Needs a fix: {type(e).__name__}: {e}") from e raise RuntimeError("Exceeded max retries")
The jitter (random.uniform) is there for a reason. When several workers hit a 429 at once, a fixed wait lines up their retries, so the instant the quota recovers they all collide again. Adding a little randomness measurably reduced those re-collisions for me.
Status-to-Action Table
So you have something to fall back on when you're stuck, here's the status and the move to make, on one page.
status / symptom
meaning
action
retry?
RESOURCE_EXHAUSTED (429)
RPM / RPD / TPM exceeded
Backoff + jitter, raise quota
yes
INVALID_ARGUMENT (400)
Bad model name / JSON / input length
Fix the request
no
UNAUTHENTICATED (401)
Missing or invalid API key
Check key and env vars
no
PERMISSION_DENIED (403)
API not enabled / no permission
Check project settings
no
INTERNAL / UNAVAILABLE (500/503)
Temporary issue on Google's side
Wait and retry, check status
yes
finish_reason=SAFETY
Blocked by safety filter
Reword the prompt neutrally
no
finish_reason=MAX_TOKENS
Hit output limit
Raise max_output_tokens
conditional
The column that earns its keep is "retry?" on the right. Retrying a FATAL case keeps the user waiting only to fail anyway. Just deciding up front whether a failure is worth waiting on changes the response your users feel.
Moving to the google-genai SDK Changes What Exceptions Look Like
Everything above assumes google-generativeai. If you move to its successor, google-genai (from google import genai), the exception hierarchy itself changes, so the unified handler has to change with it.
import osimport timeimport randomfrom google import genaifrom google.genai import errorsclient = genai.Client(api_key=os.environ["GEMINI_API_KEY"])def call_gemini(prompt, model="gemini-2.5-flash", max_retries=5, base=1.0): for attempt in range(max_retries): try: return client.models.generate_content(model=model, contents=prompt) except errors.ServerError: # 500 / 503 family. Reasonable chance of recovering if you wait. time.sleep(base * (2 ** attempt) + random.uniform(0, base)) except errors.ClientError as e: # Every 4xx lands here. Without checking code, 429 fails immediately. if e.code == 429: time.sleep(base * (2 ** attempt) + random.uniform(0, base)) continue raise RuntimeError(f"Needs a fix: {e.code} {e.message}") from e raise RuntimeError("Exceeded max retries")
Only the entry point of the decision changes; the wait-or-fix framing carries over intact. Read ServerError as covering what used to be ServiceUnavailable and InternalServerError, and ClientError as covering InvalidArgument, PermissionDenied, and ResourceExhausted together.
If retries stop working right after your migration, check for a missing e.code == 429 branch before anything else. That's exactly where I got stuck on day one of my own migration, failing fast on every rate limit without noticing for a while. The exceptions are coarser now, which means the responsibility for inspecting the code moved to your side.
Absorbing Model Deprecation and Regional Gaps in Your Own Code
The nastiest 400 is the one where a model name that was correct when you wrote it quietly stops being valid. In a shipped app, that means every user starts failing on a day when you changed nothing. It also won't reproduce on your machine, which stretches the time to diagnosis.
So I keep the model name as a sequence of candidates rather than a single constant.
MODEL_CHAIN = ("gemini-2.5-flash", "gemini-2.5-pro")def generate_with_fallback(client, prompt): last_error = None for name in MODEL_CHAIN: try: return client.models.generate_content(model=name, contents=prompt) except Exception as e: text = str(e).lower() # Only fall through for failures caused by the model name itself if "not found" in text or "is not supported" in text: last_error = e continue raise raise RuntimeError(f"No usable model available: {last_error}")
The important part is that the fallback condition is narrow. Loosen it to "any 400 moves to the next model" and genuinely fixable errors — an oversized input, say — slide silently down the chain, and the real reason disappears. A safety net stretched too wide takes your diagnostics with it.
Regions follow the same logic. Through Vertex AI, a mismatch between the model's serving region and your project's region surfaces as a 404 or PERMISSION_DENIED, neither of which sounds like a region problem. When code that works locally fails only in production, checking the region setting is where I start.
Structured Logs Are What Make Failures Traceable Later
How quickly you can isolate an error depends less on your skill at the moment it happens than on what you decided to log beforehand. A single printed string tells you nothing about which hours are worst or which model fails most often.
Collect the outcome of each call into a dict and emit it as JSON, and it becomes something you can aggregate.
import jsonimport loggingimport timelogger = logging.getLogger("gemini")def log_call(model_name, started, response=None, error=None, attempt=0): record = { "model": model_name, "elapsed_ms": int((time.monotonic() - started) * 1000), "attempt": attempt, "status": type(error).__name__ if error else "OK", } if response is not None and response.candidates: record["finish_reason"] = response.candidates[0].finish_reason.name usage = getattr(response, "usage_metadata", None) if usage is not None: record["prompt_tokens"] = usage.prompt_token_count record["output_tokens"] = usage.candidates_token_count if error is not None: record["message"] = str(error)[:200] # The prompt body stays out on purpose (it can carry user personal data) logger.info(json.dumps(record, ensure_ascii=False))
Keeping finish_reason alongside token counts lets you answer "no error, but the output is cut off" immediately — MAX_TOKENS or SAFETY. Those two look identical to the person reporting them and call for opposite fixes.
elapsed_ms earns its place too. If the hours where 500s cluster line up with the hours where latency climbs, the trouble is upstream at Google; if they don't, your own retry design is the suspect. Leaving the prompt body out is equally deliberate — once user messages start flowing into a logging platform, getting them back out is far harder than keeping them out.
What to Check, Top to Bottom, When You're Stuck
Fixing the order of your checks removes a surprising amount of hesitation. These are sorted by how often they're the cause, weighed against how cheap they are to verify.
Print the exception type and code — write the fix after this, not before
Confirm the key is in the environment — echo $GEMINI_API_KEY, checking only the first few characters
Confirm the model is still served — a name copied from an old post isn't guaranteed to be alive
Check response.candidates before touching .text
On a 429, work out which of RPM, RPD, or TPM you hit — a low request count still stalls on TPM
Check whether a test script is running against the same project — quota is shared per project, not per user
I lost half a day to number six once. When re-reading your production code turns up nothing, take it as the signal to start suspecting what sits outside the code.
Where to go next
The point of error handling isn't memorizing a list — it's being able to look at the status and finish_reason and instantly decide whether to wait or to fix. Start by routing your existing API calls through the single handler above. With branching in one place, adding support for a new error becomes a one-line change.
If you still get stuck, here are the official resources:
If this saves even a little of the time of someone stuck on the same thing, I'm glad.
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.