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.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
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)
|
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) {
|
private suspend fun get(url: String): String = withContext(Dispatchers.IO) {
|
||||||
val request = Request.Builder().url(url).header("User-Agent", USER_AGENT).build()
|
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 ->
|
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")
|
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 {
|
companion object {
|
||||||
const val USER_AGENT = "Wetter/0.1 (private, non-commercial)"
|
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.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.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
|
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 repo = Graph.repo
|
||||||
val forecast by remember(location.id) { repo.forecast(location.id) }.collectAsState(initial = null)
|
val forecast by remember(location.id) { repo.forecast(location.id) }.collectAsState(initial = null)
|
||||||
var refreshing by remember { mutableStateOf(false) }
|
var refreshing by remember { mutableStateOf(false) }
|
||||||
var failed by remember { mutableStateOf(false) }
|
var error by remember { mutableStateOf<Throwable?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val refresh: (Boolean) -> Unit = { force ->
|
val refresh: (Boolean) -> Unit = { force ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
refreshing = force
|
refreshing = force
|
||||||
failed = repo.refresh(location, force) is RefreshResult.Failed
|
error = (repo.refresh(location, force) as? RefreshResult.Failed)?.error
|
||||||
refreshing = false
|
refreshing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ private fun LocationPage(
|
|||||||
settings = settings,
|
settings = settings,
|
||||||
animate = animate,
|
animate = animate,
|
||||||
refreshing = refreshing,
|
refreshing = refreshing,
|
||||||
failed = failed,
|
error = error,
|
||||||
onRefresh = { refresh(true) },
|
onRefresh = { refresh(true) },
|
||||||
navigation = {
|
navigation = {
|
||||||
IconButton(onClick = onOpenLocations) { Icon(Icons.Rounded.FormatListBulleted, stringResource(R.string.open_locations)) }
|
IconButton(onClick = onOpenLocations) { Icon(Icons.Rounded.FormatListBulleted, stringResource(R.string.open_locations)) }
|
||||||
@@ -195,7 +195,7 @@ fun ForecastPage(
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
animate: Boolean,
|
animate: Boolean,
|
||||||
refreshing: Boolean,
|
refreshing: Boolean,
|
||||||
failed: Boolean,
|
error: Throwable?,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
navigation: @Composable () -> Unit,
|
navigation: @Composable () -> Unit,
|
||||||
actions: @Composable () -> Unit,
|
actions: @Composable () -> Unit,
|
||||||
@@ -208,6 +208,7 @@ fun ForecastPage(
|
|||||||
?: SceneSpec(app.wetter.data.Condition.CLOUDY, true)
|
?: SceneSpec(app.wetter.data.Condition.CLOUDY, true)
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val fg = spec.onScene
|
val fg = spec.onScene
|
||||||
|
val failed = error != null
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
WeatherScene(spec, animate = animate && settings.animations, scroll = {
|
WeatherScene(spec, animate = animate && settings.animations, scroll = {
|
||||||
@@ -237,7 +238,7 @@ fun ForecastPage(
|
|||||||
item(key = "loading") {
|
item(key = "loading") {
|
||||||
Column(Modifier.padding(top = 120.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(Modifier.padding(top = 120.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
if (failed) {
|
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) }
|
TextButton(onClick = onRefresh) { Text(stringResource(R.string.retry), color = fg) }
|
||||||
} else CircularProgressIndicator(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
|
@Composable
|
||||||
fun PreviewScreen(place: Place, settings: Settings, onBack: () -> Unit, onSaved: (Long) -> Unit) {
|
fun PreviewScreen(place: Place, settings: Settings, onBack: () -> Unit, onSaved: (Long) -> Unit) {
|
||||||
var forecast by remember { mutableStateOf<Forecast?>(null) }
|
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) }
|
var attempt by remember { mutableStateOf(0) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
LaunchedEffect(place, attempt) {
|
LaunchedEffect(place, attempt) {
|
||||||
failed = false
|
error = null
|
||||||
runCatching { Graph.repo.preview(place) }.onSuccess { forecast = it }.onFailure { failed = true }
|
runCatching { Graph.repo.preview(place) }.onSuccess { forecast = it }.onFailure { error = it }
|
||||||
}
|
}
|
||||||
ForecastPage(
|
ForecastPage(
|
||||||
title = place.name,
|
title = place.name,
|
||||||
@@ -39,7 +39,7 @@ fun PreviewScreen(place: Place, settings: Settings, onBack: () -> Unit, onSaved:
|
|||||||
settings = settings,
|
settings = settings,
|
||||||
animate = true,
|
animate = true,
|
||||||
refreshing = false,
|
refreshing = false,
|
||||||
failed = failed,
|
error = error,
|
||||||
onRefresh = { attempt++ },
|
onRefresh = { attempt++ },
|
||||||
navigation = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) } },
|
navigation = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) } },
|
||||||
actions = {
|
actions = {
|
||||||
|
|||||||
@@ -93,6 +93,8 @@
|
|||||||
<string name="perm_allow">Standort erlauben</string>
|
<string name="perm_allow">Standort erlauben</string>
|
||||||
<string name="perm_search">Lieber einen Ort suchen</string>
|
<string name="perm_search">Lieber einen Ort suchen</string>
|
||||||
<string name="loading_error">Wetterdaten konnten nicht geladen werden</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_units">Einheiten</string>
|
||||||
<string name="set_temp">Temperatur</string>
|
<string name="set_temp">Temperatur</string>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class ScreenshotTest {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun Home(f: Forecast, title: String = "Berlin", preview: Boolean = false) = ForecastPage(
|
private fun Home(f: Forecast, title: String = "Berlin", preview: Boolean = false) = ForecastPage(
|
||||||
title = title, isCurrent = !preview, forecast = f, settings = Settings(), animate = false,
|
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) } },
|
navigation = { IconButton(onClick = {}) { Icon(Icons.Rounded.FormatListBulleted, null) } },
|
||||||
actions = {
|
actions = {
|
||||||
if (preview) FilledTonalButton(onClick = {}) { Text("Speichern") }
|
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