Skip to content

Commit b080797

Browse files
Merge pull request #65 from Android-PowerUser/plan-for-api-rate-limit-handling
Mistral: per-key rate limiting, retry/backoff and improved error handling for streaming requests
2 parents 2d78fef + d139fcc commit b080797

1 file changed

Lines changed: 117 additions & 47 deletions

File tree

app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningViewModel.kt

Lines changed: 117 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import kotlinx.serialization.modules.subclass
7070
import com.google.ai.sample.webrtc.WebRTCSender
7171
import com.google.ai.sample.webrtc.SignalingClient
7272
import org.webrtc.IceCandidate
73+
import kotlin.math.max
7374

7475
class PhotoReasoningViewModel(
7576
application: Application,
@@ -182,9 +183,11 @@ class PhotoReasoningViewModel(
182183
// to avoid re-executing already-executed commands
183184
private var incrementalCommandCount = 0
184185

185-
// Mistral rate limiting: track last request time to enforce 1-second minimum interval
186-
private var lastMistralRequestTimeMs = 0L
187-
private val MISTRAL_MIN_INTERVAL_MS = 1000L
186+
// Mistral rate limiting per API key (1.1 seconds between requests with same key)
187+
private val mistralNextAllowedRequestAtMsByKey = mutableMapOf<String, Long>()
188+
private var lastMistralTokenTimeMs = 0L
189+
private var lastMistralTokenKey: String? = null
190+
private val MISTRAL_MIN_INTERVAL_MS = 1100L
188191

189192
// Accumulated full text during streaming for incremental command parsing
190193
private var streamingAccumulatedText = StringBuilder()
@@ -1052,12 +1055,6 @@ private fun reasonWithMistral(
10521055
resetStreamingCommandState()
10531056

10541057
viewModelScope.launch(Dispatchers.IO) {
1055-
// Rate limiting: nur die verbleibende Zeit warten
1056-
val elapsed = System.currentTimeMillis() - lastMistralRequestTimeMs
1057-
if (lastMistralRequestTimeMs > 0 && elapsed < MISTRAL_MIN_INTERVAL_MS) {
1058-
delay(MISTRAL_MIN_INTERVAL_MS - elapsed)
1059-
}
1060-
10611058
try {
10621059
val currentModel = com.google.ai.sample.GenerativeAiViewModelFactory.getCurrentModel()
10631060
val genSettings = com.google.ai.sample.util.GenerationSettingsPreferences.loadSettings(context, currentModel.modelName)
@@ -1126,60 +1123,133 @@ private fun reasonWithMistral(
11261123
.addHeader("Authorization", "Bearer $key")
11271124
.build()
11281125

1129-
var currentKey = initialApiKey
1130-
var response = client.newCall(buildRequest(currentKey)).execute()
1131-
lastMistralRequestTimeMs = System.currentTimeMillis()
1126+
val availableKeys = apiKeyManager.getApiKeys(ApiProvider.MISTRAL)
1127+
.filter { it.isNotBlank() }
1128+
.distinct()
1129+
if (availableKeys.isEmpty()) {
1130+
throw IOException("Mistral API key not found.")
1131+
}
11321132

1133-
if (response.code == 429) {
1134-
response.close()
1135-
apiKeyManager.markKeyAsFailed(currentKey, ApiProvider.MISTRAL)
1136-
val nextKey = apiKeyManager.switchToNextAvailableKey(ApiProvider.MISTRAL)
1137-
if (nextKey != null && nextKey != currentKey) {
1138-
// Anderer Key verfugbar -> sofort wechseln wie bisher
1139-
currentKey = nextKey
1140-
val elapsed2 = System.currentTimeMillis() - lastMistralRequestTimeMs
1141-
if (elapsed2 < MISTRAL_MIN_INTERVAL_MS) delay(MISTRAL_MIN_INTERVAL_MS - elapsed2)
1142-
response = client.newCall(buildRequest(currentKey)).execute()
1143-
lastMistralRequestTimeMs = System.currentTimeMillis()
1144-
} else {
1145-
// Kein anderer Key -> 5 Sekunden lang sofort wiederholen
1146-
apiKeyManager.resetFailedKeys(ApiProvider.MISTRAL)
1133+
// Validate that we have at least one key before proceeding
1134+
require(availableKeys.isNotEmpty()) { "No valid Mistral API keys available after filtering" }
1135+
1136+
fun markKeyCooldown(key: String, referenceTimeMs: Long) {
1137+
val nextAllowedAt = referenceTimeMs + MISTRAL_MIN_INTERVAL_MS
1138+
val existing = mistralNextAllowedRequestAtMsByKey[key] ?: 0L
1139+
mistralNextAllowedRequestAtMsByKey[key] = max(existing, nextAllowedAt)
1140+
}
1141+
1142+
fun remainingWaitForKeyMs(key: String, nowMs: Long): Long {
1143+
val nextAllowedAt = mistralNextAllowedRequestAtMsByKey[key] ?: 0L
1144+
return (nextAllowedAt - nowMs).coerceAtLeast(0L)
1145+
}
1146+
1147+
fun isRetryableMistralFailure(code: Int): Boolean {
1148+
return code == 429 || code >= 500
1149+
}
1150+
1151+
var response: okhttp3.Response? = null
1152+
var selectedKeyForResponse: String? = null
1153+
var consecutiveFailures = 0
1154+
var blockedKeysThisRound = mutableSetOf<String>()
1155+
1156+
val maxAttempts = availableKeys.size * 2 + 3 // Allow cycling through all keys at least twice
1157+
while (response == null && consecutiveFailures < maxAttempts) {
1158+
if (stopExecutionFlag.get()) break
1159+
1160+
val now = System.currentTimeMillis()
1161+
val keyPool = availableKeys.filter { it !in blockedKeysThisRound }.ifEmpty {
1162+
blockedKeysThisRound.clear()
1163+
availableKeys
1164+
}
1165+
1166+
val keyWithLeastWait = keyPool.minByOrNull { remainingWaitForKeyMs(it, now) } ?: availableKeys.first()
1167+
val waitMs = remainingWaitForKeyMs(keyWithLeastWait, now)
1168+
if (waitMs > 0L) {
1169+
delay(waitMs)
1170+
}
1171+
1172+
val selectedKey = keyWithLeastWait
1173+
selectedKeyForResponse = selectedKey
1174+
1175+
try {
1176+
val attemptResponse = client.newCall(buildRequest(selectedKey)).execute()
1177+
val requestEndMs = System.currentTimeMillis()
1178+
markKeyCooldown(selectedKey, requestEndMs)
1179+
1180+
if (attemptResponse.isSuccessful) {
1181+
response = attemptResponse
1182+
break
1183+
}
1184+
1185+
val isRetryable = isRetryableMistralFailure(attemptResponse.code)
1186+
if (!isRetryable) {
1187+
val errBody = attemptResponse.body?.string()
1188+
attemptResponse.close()
1189+
throw IllegalStateException("Mistral Error ${attemptResponse.code}: $errBody")
1190+
}
1191+
1192+
attemptResponse.close()
1193+
blockedKeysThisRound.add(selectedKey)
1194+
consecutiveFailures++
11471195
withContext(Dispatchers.Main) {
1148-
replaceAiMessageText("Rate limit erreicht. Wiederhole...", isPending = true)
1196+
replaceAiMessageText(
1197+
"Mistral temporär nicht verfügbar (Versuch $consecutiveFailures/$maxAttempts). Wiederhole...",
1198+
isPending = true
1199+
)
11491200
}
1150-
val retryDeadline = System.currentTimeMillis() + 5000L
1151-
var retryResponse: okhttp3.Response? = null
1152-
while (System.currentTimeMillis() < retryDeadline) {
1153-
if (stopExecutionFlag.get()) break
1154-
val retryResp = client.newCall(buildRequest(currentKey)).execute()
1155-
lastMistralRequestTimeMs = System.currentTimeMillis()
1156-
if (retryResp.code != 429) {
1157-
retryResponse = retryResp
1158-
break
1159-
}
1160-
retryResp.close()
1201+
} catch (e: IOException) {
1202+
val requestEndMs = System.currentTimeMillis()
1203+
markKeyCooldown(selectedKey, requestEndMs)
1204+
blockedKeysThisRound.add(selectedKey)
1205+
consecutiveFailures++
1206+
if (consecutiveFailures >= 5) {
1207+
throw IOException("Mistral request failed after 5 attempts: ${e.message}", e)
11611208
}
1162-
if (retryResponse == null || stopExecutionFlag.get()) {
1163-
throw IOException("Mistral rate limit: Kein Erfolg innerhalb von 5 Sekunden.")
1209+
withContext(Dispatchers.Main) {
1210+
replaceAiMessageText(
1211+
if (consecutiveFailures >= maxAttempts) {
1212+
throw IOException("Mistral request failed after $maxAttempts attempts: ${e.message}", e)
1213+
)
11641214
}
1165-
response = retryResponse
11661215
}
1216+
"Mistral Netzwerkfehler (Versuch $consecutiveFailures/$maxAttempts). Wiederhole...",
1217+
1218+
if (stopExecutionFlag.get()) {
1219+
throw IOException("Mistral request aborted.")
11671220
}
11681221

1169-
if (!response.isSuccessful) {
1170-
val errBody = response.body?.string()
1171-
response.close()
1172-
throw IOException("Mistral Error ${response.code}: $errBody")
1222+
val finalResponse = response ?: throw IOException("Mistral request failed after 5 attempts.")
1223+
1224+
if (!finalResponse.isSuccessful) {
1225+
val errBody = finalResponse.body?.string()
1226+
finalResponse.close()
1227+
val finalResponse = response ?: throw IOException("Mistral request failed after $maxAttempts attempts.")
11731228
}
11741229

1175-
val body = response.body ?: throw IOException("Empty response body from Mistral")
1230+
val body = finalResponse.body ?: throw IOException("Empty response body from Mistral")
11761231
val aiResponseText = openAiStreamParser.parse(body) { accText ->
1232+
selectedKeyForResponse?.let { key ->
1233+
lastMistralTokenKey = key
1234+
lastMistralTokenTimeMs = System.currentTimeMillis()
1235+
markKeyCooldown(key, lastMistralTokenTimeMs)
1236+
} ?: run {
1237+
Log.w(TAG, "selectedKeyForResponse is null during streaming callback")
1238+
}
11771239
withContext(Dispatchers.Main) {
11781240
replaceAiMessageText(accText, isPending = true)
11791241
processCommandsIncrementally(accText)
11801242
}
11811243
}
1182-
response.close()
1244+
finalResponse.close()
1245+
selectedKeyForResponse?.let { key ->
1246+
val reference = if (lastMistralTokenKey == key && lastMistralTokenTimeMs > 0L) {
1247+
lastMistralTokenTimeMs
1248+
} else {
1249+
System.currentTimeMillis()
1250+
}
1251+
markKeyCooldown(key, reference)
1252+
}
11831253

11841254
withContext(Dispatchers.Main) {
11851255
_uiState.value = PhotoReasoningUiState.Success(aiResponseText)

0 commit comments

Comments
 (0)