ScamCheck's Gemini scam detection Cloud Function hit the free tier rate limit (429 Too Many Requests) during rapid testing. The client had no handling for the 429 case and showed an indefinite spinning loader. Root cause: the Cloud Function did not return a structured error response for 429, and the client had no branch for anything other than success. Fix: return { rateLimited: true } from the Cloud Function on 429, detect it client-side, and render a specific message.
Resolution Steps
During ScamCheck development (February 2026), a testing session involved submitting multiple scam check requests in quick succession to verify the verdict display across different input types. After 3–4 requests within a minute, new submissions stopped returning results.
The submit button showed a loading spinner. The spinner did not resolve. No error message appeared. The app appeared frozen.
Checking Firebase Console → Functions → Logs showed the Cloud Function was receiving the request, calling the Gemini API, and then crashing with:
Error: 429 Too Many Requests
at ...
The Gemini API free tier applies a per-minute rate limit on the number of requests. The limit is not high — it is designed for development, not production load. Submitting 3–4 analysis requests within a minute was enough to hit it.
The Cloud Function's error handling at the time of the failure was minimal:
// Cloud Function — before fix (simplified)
exports.analyzeText = functions.https.onCall(async (data, context) => {
const result = await gemini.generateContent(prompt)
return { verdict: parseVerdict(result) }
})
When Gemini returned 429, gemini.generateContent(prompt) threw an unhandled exception. Firebase Cloud Functions propagate unhandled exceptions to the client as an internal HTTPS error with no structured payload. The React client was handling this as a generic network error:
// React client — before fix (simplified)
try {
const { data } = await analyzeText({ text: inputText })
setVerdict(data.verdict)
} catch (error) {
// Generic catch — no handling for rate limit specifically
console.error(error)
// spinner stays on — setVerdict never called
}
The catch block logged the error but never called setVerdict or reset the loading state. Result: the button remained in loading state indefinitely until the user refreshed the page.
A 429 from Gemini is not a bug. It is expected behavior on the free tier. It requires a distinct user-facing response:
| Error type | User should see |
|---|---|
| Network error | "Analysis failed — check your connection and try again" |
| Server error (500) | "Analysis failed — please try again" |
| Rate limit (429) | "Rate limit reached — please wait a few seconds and try again" |
Lumping 429 into the generic catch bucket means the user has no signal that waiting will fix the problem. They see a frozen UI and typically either keep clicking (making the problem worse) or leave.
Cloud Function — after fix:
exports.analyzeText = functions.https.onCall(async (data, context) => {
try {
const result = await gemini.generateContent(prompt)
return { verdict: parseVerdict(result) }
} catch (error) {
// Gemini rate limit — expected on free tier
if (error.status === 429 || error.message?.includes('429')) {
return { rateLimited: true }
}
// Parse failure — return structured error verdict instead of crashing
if (error instanceof SyntaxError) {
return { parseError: true }
}
throw error // Re-throw unexpected errors for Firebase to handle
}
})
Returning { rateLimited: true } as an HTTP 200 response (rather than throwing) means the client receives a structured object it can branch on cleanly. The Firebase client SDK only throws on HTTP errors — returning 200 with a payload keeps the client in the normal code path.
React client — after fix:
const { data } = await analyzeText({ text: inputText })
if (data.rateLimited) {
setErrorMessage('Rate limit reached — please wait a few seconds and try again')
setLoading(false)
return
}
setVerdict(data.verdict)
setLoading(false)
The submit button is re-enabled after a rate limit response. The user can retry after waiting. No page refresh required.
The Gemini API free tier (as of February 2026) enforces limits per minute and per day. The per-minute limit is the constraint hit in normal use — multiple users or a single user testing rapidly can exceed it within seconds. The per-day limit is rarely hit during development but becomes relevant in early production with real user traffic.
Mitigation strategies beyond the UX fix:
Disable the submit button during in-flight requests — prevents the user from submitting additional requests before the current one resolves. This alone reduces rate limit frequency during normal use (a user who cannot submit again does not accidentally queue up 3 requests).
Add a client-side cooldown — after a successful analysis, prevent resubmission for 3–5 seconds. Most legitimate use cases involve reading the verdict between submissions.
Upgrade to paid tier — the paid Gemini API tier has significantly higher rate limits appropriate for production workloads.
Implement server-side request queuing — for production deployments expecting high concurrency, queue requests in the Cloud Function and process them sequentially with delays. This adds latency but prevents rate limit exhaustion from simultaneous users.
For ScamCheck's current scale (single-operator development), the UX fix plus submit button disabling during in-flight requests was sufficient. Rate limit hits became informative rather than confusing.
While fixing the 429 handling, a second issue was found: if Gemini returned a valid response but with malformed JSON (non-standard formatting, markdown code fences around the JSON), JSON.parse() would throw, the Cloud Function would crash, and the client would receive the same spinner-forever behavior.
This was also handled in the same fix — SyntaxError is now caught and returns { parseError: true }. The client renders "Could not parse analysis result" with a retry button.
The pattern: any failure mode that leaves the client spinner running indefinitely is a UX bug, regardless of its technical root cause.
{ rateLimited: true }) rather than throwing exceptions for expected error conditions — keeps client code in the normal branchFix Confidence
Recovery Complexity
Pattern Family
This failure belongs to a named recurring pattern. Other failures in this family share the same root cause structure — understanding the pattern prevents multiple failure types simultaneously.
Related Failures in Same Pattern
Demonstrated In
This failure occurred in a real production context. These case studies show the full arc from incident to resolution.