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:
@@ -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,13 +155,25 @@ 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()
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
val result = runCatching {
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
|
||||
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 {
|
||||
const val USER_AGENT = "Wetter/0.1 (private, non-commercial)"
|
||||
@@ -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
Reference in New Issue
Block a user