🧠🔥 I Replaced My “Dumb” Ask NHL With AI… and It Actually Slaps 🤯🏒

/**
* Talks to Gemini to answer free-text NHL questions from the “Ask NHL” screen.
*/
class AskNhlRepository @Inject constructor(
private val geminiApi: GeminiApi,
) {

/**
* Sends [question] to Gemini and returns the generated answer text.
* Wrapped in a Result so the ViewModel can cleanly show an error state
* instead of crashing or leaking a raw exception into the UI layer.
*
* Retries transient failures (rate limits, 5xx, network blips) with
* exponential backoff before giving up — Gemini free-tier flash throws
* 503 “overloaded” fairly often, so one failed attempt isn’t treated
* as a real failure yet.
*/
suspend fun askQuestion(question: String): Result {
return try {
val answerText = withRetry {
val request = GeminiRequest(
contents = listOf(
GeminiContent(parts = listOf(GeminiPart(text = buildPrompt(question))))
)
)

val response = geminiApi.generateContent(request = request)

// Gemini nests the answer text pretty deep: candidates[0].content.parts[0].text
response.candidates
?.firstOrNull()
?.content
?.parts
?.firstOrNull()
?.text
}

if (answerText.isNullOrBlank()) {
Result.failure(IllegalStateException(“Gemini returned an empty response”))
} else {
Result.success(answerText.trim())
}
} catch (e: Exception) {
// Anything that escapes withRetry (a non-retryable HTTP error,
// or retries exhausted) lands here as a Result.failure instead
// of propagating up and crashing the caller.
Result.failure(e)
}
}

/**
* Runs [block] up to [maxAttempts] times with exponential backoff
* between failures.
*
* maxAttempts is 2 (not 3): with a 20s per-attempt timeout (see
* NetworkModule), 2 attempts caps the worst-case user wait around ~40s
* plus a small backoff delay. A 3rd attempt rarely rescues a request
* that’s failed twice and just makes the user wait longer for the
* same likely outcome — not worth it for a chat-style UI on MVP.
*
* Only retries error types that are likely transient — a 401 or a
* malformed request will fail identically every time, so those are
* rethrown immediately instead of wasting an attempt on them.
*/
private suspend fun withRetry(
maxAttempts: Int = 2,
initialDelayMs: Long = 1_000,
maxDelayMs: Long = 8_000,
block: suspend () -> T,
): T {
var lastError: Throwable? = null

repeat(maxAttempts) { attempt ->
try {
// Success — return immediately, skipping any remaining attempts.
return block()
} catch (e: HttpException) {
// A 429 with a *daily* quota ID means retrying is pointless —
// the limit doesn’t reset for hours, not for a backoff delay.
// Read the error body once here and bail immediately instead
// of burning an attempt (and the user’s time) on a retry that
// cannot succeed.
if (e.code() == 429 && e.isDailyQuotaExhausted()) {
throw e
}

val retryable = e.code() in setOf(429, 500, 502, 503, 504)
if (!retryable) throw e
lastError = e
} catch (e: IOException) {
// No connection / timeout / DNS failure — always transient,
// always worth a retry.
lastError = e
}

// Exponential backoff: 1s, 2s, … capped at maxDelayMs.
// Jitter (random extra delay up to 25% of backoff) spreads out
// retries from multiple concurrent requests so they don’t all
// retry in lockstep and cause another spike.
val backoff = (initialDelayMs * 2.0.pow(attempt))
.toLong()
.coerceAtMost(maxDelayMs)
val jitter = Random.nextLong(0, backoff / 4 + 1)
delay(backoff + jitter)
}

throw lastError ?: IllegalStateException(“Gemini request failed with no captured error”)
}

/**
* Wraps the raw user question with instructions so Gemini stays
* on-topic and keeps answers short enough to fit the UI.
*/
private fun buildPrompt(question: String): String = “””
You are the “Ask NHL” assistant inside an NHL fan app. Answer the user’s
hockey question directly and concisely (2-4 sentences max). If the question
isn’t about hockey or the NHL, politely say you can only help with NHL topics.

Question: $question
“””.trimIndent()
}

/**
* Checks whether a 429’s error body indicates the *daily* quota (RPD request per minute) was
* exhausted, as opposed to a per-minute rate limit (RPM), which IS worth
* retrying. Google’s error body includes a “quotaId” string that contains
* “PerDay” for daily quota violations — that’s the signal we key off of.
* errorBody() can only be read once per HttpException, so this reads and
* discards it; callers shouldn’t try to read the body again afterward.
*/
private fun HttpException.isDailyQuotaExhausted(): Boolean {
val body = response()?.errorBody()?.string() ?: return false
return body.contains(“PerDay”, ignoreCase = true)
}

/**
* Converts a failure from askQuestion() into copy that’s safe to show a
* user. Lives near the repository rather than in the ViewModel, since
* “what does this error mean to a user” is closer to a networking concern
* than a UI-state concern.
*/
fun Throwable.toAskNhlUserMessage(): String = when (this) {
is HttpException -> when {
code() == 429 && isDailyQuotaExhausted() ->
“Ask NHL has hit its free daily limit — check back tomorrow!”
code() == 503 || code() == 429 ->
“NHL brain is a little overloaded right now — give it a sec and try again.”
code() == 401 || code() == 403 ->
“Something’s wrong on our end, not you. We’re on it.”
else -> “Couldn’t get an answer for that one. Try rephrasing?”
}
is IOException -> “Looks like you’re offline — check your connection and try again.”
else -> “Something went sideways. Try again?”
}

Similar Posts

Leave a Reply