fix: retry Open-Meteo on 429/5xx and show why loading failed

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 14:09:21 +02:00
co-authored by Claude Opus 5.5
parent c61b03b48f
commit 27bf4e88f4
8 changed files with 41 additions and 13 deletions
@@ -17,6 +17,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import kotlinx.coroutines.delay
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
@@ -154,12 +155,24 @@ class OpenMeteo(private val http: OkHttpClient = defaultClient()) {
return Air(c.int("european_aqi"), c.num("pm2_5"), c.num("pm10"), pollen)
}
/** Open-Meteo answers 429/5xx ("service is overloaded") now and then; retry those twice with backoff. */
private suspend fun get(url: String): String = withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).header("User-Agent", USER_AGENT).build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
response.body?.string() ?: throw IOException("empty body")
var attempt = 0
while (true) {
val result = runCatching {
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw HttpException(response.code)
response.body?.string() ?: throw IOException("empty body")
}
}
val error = result.exceptionOrNull() ?: return@withContext result.getOrThrow()
val retryable = error is IOException && (error !is HttpException || error.code == 429 || error.code >= 500)
if (!retryable || attempt >= 2) throw error
delay(600L * (attempt + 1) * (attempt + 1))
attempt++
}
@Suppress("UNREACHABLE_CODE") error("unreachable")
}
companion object {
@@ -189,3 +202,5 @@ private fun JsonObject.int(key: String) = num(key)?.toInt()
private fun JsonObject.arr(key: String): JsonArray = this[key]?.jsonArray ?: JsonArray(emptyList())
private fun JsonObject.numAt(key: String, i: Int) = arr(key).getOrNull(i)?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull
private fun JsonObject.intAt(key: String, i: Int) = numAt(key, i)?.toInt() ?: arr(key).getOrNull(i)?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull
class HttpException(val code: Int) : IOException("HTTP $code")
@@ -152,12 +152,12 @@ private fun LocationPage(
val repo = Graph.repo
val forecast by remember(location.id) { repo.forecast(location.id) }.collectAsState(initial = null)
var refreshing by remember { mutableStateOf(false) }
var failed by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<Throwable?>(null) }
val scope = rememberCoroutineScope()
val refresh: (Boolean) -> Unit = { force ->
scope.launch {
refreshing = force
failed = repo.refresh(location, force) is RefreshResult.Failed
error = (repo.refresh(location, force) as? RefreshResult.Failed)?.error
refreshing = false
}
}
@@ -172,7 +172,7 @@ private fun LocationPage(
settings = settings,
animate = animate,
refreshing = refreshing,
failed = failed,
error = error,
onRefresh = { refresh(true) },
navigation = {
IconButton(onClick = onOpenLocations) { Icon(Icons.Rounded.FormatListBulleted, stringResource(R.string.open_locations)) }
@@ -195,7 +195,7 @@ fun ForecastPage(
settings: Settings,
animate: Boolean,
refreshing: Boolean,
failed: Boolean,
error: Throwable?,
onRefresh: () -> Unit,
navigation: @Composable () -> Unit,
actions: @Composable () -> Unit,
@@ -208,6 +208,7 @@ fun ForecastPage(
?: SceneSpec(app.wetter.data.Condition.CLOUDY, true)
val listState = rememberLazyListState()
val fg = spec.onScene
val failed = error != null
Box(Modifier.fillMaxSize()) {
WeatherScene(spec, animate = animate && settings.animations, scroll = {
@@ -237,7 +238,7 @@ fun ForecastPage(
item(key = "loading") {
Column(Modifier.padding(top = 120.dp), horizontalAlignment = Alignment.CenterHorizontally) {
if (failed) {
Text(stringResource(R.string.loading_error), color = fg, style = WType.body)
Text(stringResource(errorText(error)), color = fg, style = WType.body, textAlign = TextAlign.Center)
TextButton(onClick = onRefresh) { Text(stringResource(R.string.retry), color = fg) }
} else CircularProgressIndicator(color = fg)
}
@@ -310,3 +311,10 @@ private fun PermissionPrompt(onAllow: () -> Unit, onSearch: () -> Unit, asked: B
}
}
}
@androidx.annotation.StringRes
private fun errorText(error: Throwable?): Int = when {
error is app.wetter.data.HttpException && (error.code == 429 || error.code >= 500) -> R.string.loading_error_busy
error is java.net.UnknownHostException || error is java.net.ConnectException || error is java.net.SocketTimeoutException -> R.string.loading_error_offline
else -> R.string.loading_error
}
@@ -25,12 +25,12 @@ import kotlinx.coroutines.launch
@Composable
fun PreviewScreen(place: Place, settings: Settings, onBack: () -> Unit, onSaved: (Long) -> Unit) {
var forecast by remember { mutableStateOf<Forecast?>(null) }
var failed by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<Throwable?>(null) }
var attempt by remember { mutableStateOf(0) }
val scope = rememberCoroutineScope()
LaunchedEffect(place, attempt) {
failed = false
runCatching { Graph.repo.preview(place) }.onSuccess { forecast = it }.onFailure { failed = true }
error = null
runCatching { Graph.repo.preview(place) }.onSuccess { forecast = it }.onFailure { error = it }
}
ForecastPage(
title = place.name,
@@ -39,7 +39,7 @@ fun PreviewScreen(place: Place, settings: Settings, onBack: () -> Unit, onSaved:
settings = settings,
animate = true,
refreshing = false,
failed = failed,
error = error,
onRefresh = { attempt++ },
navigation = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) } },
actions = {
@@ -93,6 +93,8 @@
<string name="perm_allow">Standort erlauben</string>
<string name="perm_search">Lieber einen Ort suchen</string>
<string name="loading_error">Wetterdaten konnten nicht geladen werden</string>
<string name="loading_error_busy">Der Wetterdienst ist gerade überlastet. Bitte gleich noch einmal versuchen.</string>
<string name="loading_error_offline">Keine Verbindung zum Internet</string>
<string name="set_units">Einheiten</string>
<string name="set_temp">Temperatur</string>
@@ -62,7 +62,7 @@ class ScreenshotTest {
@Composable
private fun Home(f: Forecast, title: String = "Berlin", preview: Boolean = false) = ForecastPage(
title = title, isCurrent = !preview, forecast = f, settings = Settings(), animate = false,
refreshing = false, failed = false, onRefresh = {},
refreshing = false, error = null, onRefresh = {},
navigation = { IconButton(onClick = {}) { Icon(Icons.Rounded.FormatListBulleted, null) } },
actions = {
if (preview) FilledTonalButton(onClick = {}) { Text("Speichern") }
@@ -0,0 +1 @@
{"reason":"The service is overloaded","error":true}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long