I came across a forum post from someone building an Android app that reads photos of circuit diagrams with Gemini. The app told users the model could not be found and to check the model name in settings. Their Logcat, though, showed 404 NOT_FOUND and 429 RESOURCE_EXHAUSTED side by side. They could not tell whether the culprit was the model name, the API version, a quota, the key, or their own retry logic.
I went back to see whether anyone had answered. Nobody had. Reading it again, I realized the calls I write could produce the same pair of lines under the right conditions. As an indie developer who uses Gemini for image classification in a wallpaper app, I can't treat this as someone else's problem. So instead of guessing at an answer, I decided to settle on the order in which to check things.
The first thing to say: 404 and 429 are different questions
The official error list splits 404 into not_found and model_not_found, and 429 into rate_limit_exceeded, quota_exceeded and too_many_requests. Appearing on the same screen does not make them the same question.
| What you see | What it is asking | Will waiting fix it? |
|---|---|---|
| 404 model_not_found | Can this key see a model with that name? | No. Fix the name or your eligibility |
| 429 rate_limit_exceeded | Did you send too much in a short window? | Yes, it clears |
| 429 quota_exceeded | Is today's allowance used up? | Not until it resets |
The line I drew is a single one: do not read a 429 as a quota story when the name was never resolved. If you start staring at quota graphs before the name is settled, you may be investigating a different cause entirely.
Why both can appear together: what I could confirm and what I could not
To be clear, the thread had no reply that settled the cause, so I can't claim that a wrong model name produces a false 429.
Two things I can say. If the app falls back to another model on failure, one user action turns into several requests. And those requests can fail for different reasons. An outdated primary name returns 404, while a real fallback model returns 429 because several requests were sent in a burst. Looking at only one half of the log, that looks contradictory.
So what you need is a per-line table: which request, which model name, which code. Counts won't show it.
Check the name first, without generating anything
Whether a model name exists can be checked with models.get, which generates nothing and so does not use up your generation quota. That means it is safe to send even while you are being rate limited. The script below classifies in the order name first, quota second.
import json, urllib.request, urllib.error
BASE = "https://generativelanguage.googleapis.com/v1beta"
def call(path, key, body=None):
req = urllib.request.Request(
f"{BASE}/{path}",
data=json.dumps(body).encode() if body else None,
headers={"x-goog-api-key": key, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=20) as r:
return r.status, json.load(r)
except urllib.error.HTTPError as e:
try:
return e.code, json.load(e)
except Exception:
return e.code, {}
def classify(model, key):
# 1) Check the name only. No generation, so no quota used
status, _ = call(f"models/{model}", key)
if status == 404:
return "MODEL_NAME", "Key can't see this name. Suspect spelling, generation, or access limits"
if status in (400, 403):
return "KEY_OR_PROJECT", f"Key or project problem ({status})"
# 2) Only if the name resolves, probe quota with a 1-token generation
status, body = call(
f"models/{model}:generateContent", key,
{"contents": [{"parts": [{"text": "ping"}]}],
"generationConfig": {"maxOutputTokens": 1}})
if status == 429:
return "QUOTA", "A real limit. Wait, or revisit your allowance"
if status == 200:
return "OK", "The name works. Suspect the app's retry or fallback logic"
return "OTHER", f"HTTP {status}"
# Example: classify("your-model-name", "YOUR_API_KEY")Replace YOUR_API_KEY with your own key. The probe is capped at one output token so the check itself barely touches your allowance.
I checked the branches without a real key
I did not test this against the live API. I swapped in canned responses for four cases: a misspelled name, an exhausted quota, everything succeeding, and a rejected key. They came out as MODEL_NAME, QUOTA, OK and KEY_OR_PROJECT respectively.
That only shows my branching does what I intended. What the real API returns for your key is something to confirm by running it once yourself. If the result is OK, move your attention to how the app retries.
Log the requested name and the returned code on one line
To build that per-line table you need one log line per request. Nothing elaborate:
import time
def log_attempt(op_id, model, status):
# Lets you line up how many calls one action made, with which model, and what came back
print(f"{time.strftime('%H:%M:%S')} op={op_id} model={model} status={status}")Give each user action a single op_id and you can see both that one action became three requests and that a 404 was followed by a 429. Before adding this line, all I could say was "404 and 429 both appear." With it, I can say "the primary is the 404, the fallback is the 429."
If it still isn't fixed
If you get OK but the app keeps failing, suspect the number of calls. Temporarily turn off the fall-back-to-another-model path and count how many requests a single user action sends. If the name is not visible, note that a model can stop working even when the official table lists no shutdown date, as in this 2.5 Flash 404 case. When the model list returns 200 but something else blocks you, the 403 triage order is a useful companion.
For the kind of 429 that waiting never fixes, I wrote out the retry design with working code in a premium article.
Your next step: take one model name that returned 404 in your logs and pass it to the script above. Name first, quota second. That is the order I intend to keep the next time I get stuck.