Skip to content

Commit 220fb1f

Browse files
committed
improve Stats failure handling
1 parent 73e5411 commit 220fb1f

File tree

4 files changed

+94
-62
lines changed

4 files changed

+94
-62
lines changed

api/client/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ plugins {
44

55
dependencies {
66
api(projects.api)
7-
implementation(projects.util.okhttpKts)
7+
api(projects.util.okhttpKts)
88

99
implementation(libs.kotlinx.serialization.json)
1010

Lines changed: 89 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
@file:OptIn(ExperimentalTime::class)
2+
13
package de.binarynoise.captiveportalautologin
24

5+
import java.io.FileNotFoundException
6+
import kotlin.time.Clock
7+
import kotlin.time.Duration.Companion.minutes
8+
import kotlin.time.Duration.Companion.seconds
9+
import kotlinx.coroutines.delay
310
import android.content.Context
411
import androidx.work.Constraints
512
import androidx.work.CoroutineWorker
@@ -14,7 +21,10 @@ import de.binarynoise.captiveportalautologin.preferences.SharedPreferences
1421
import de.binarynoise.captiveportalautologin.util.applicationContext
1522
import de.binarynoise.filedb.JsonDB
1623
import de.binarynoise.logger.Logger.log
24+
import de.binarynoise.util.okhttp.HttpStatusCodeException
1725
import okhttp3.HttpUrl.Companion.toHttpUrl
26+
import kotlin.time.ExperimentalTime
27+
import kotlin.time.Instant
1828

1929
const val API_BASE = "https://captiveportalautologin.binarynoise.de/api/"
2030

@@ -24,50 +34,83 @@ private val jsonDB = JsonDB(localCacheRoot)
2434
// Worker class to handle the upload
2535
class StatsWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
2636

37+
var lastRetryTime by SharedPreferences.stats_last_retry_time
38+
private val retryCooldown = 5.minutes
39+
40+
private fun hasRecentRetry(): Boolean {
41+
return (Clock.System.now() - Instant.fromEpochMilliseconds(lastRetryTime)) < retryCooldown
42+
}
43+
44+
private fun recordRetry() {
45+
lastRetryTime = Clock.System.now().toEpochMilliseconds()
46+
}
47+
2748
override suspend fun doWork(): Result {
2849
log("StatsWorker started")
29-
val apiBaseFromPreference by SharedPreferences.api_base
30-
val apiClient = ApiClient((apiBaseFromPreference.takeUnless { it == "" } ?: API_BASE).toHttpUrl())
3150

32-
val harFiles = jsonDB.loadAll<HAR>("har")
33-
harFiles.forEach { (key, har) ->
34-
try {
35-
apiClient.har.submitHar(key, har)
36-
jsonDB.delete<HAR>(key, "har")
37-
log("Uploaded HAR $key")
38-
} catch (e: Exception) {
39-
log("Failed to upload har", e)
40-
// retry on the next worker run
41-
return Result.retry()
42-
}
51+
if (hasRecentRetry()) {
52+
log("Recent retry detected, delaying this attempt to avoid spamming")
53+
delay(retryCooldown)
4354
}
4455

45-
val errorFiles = jsonDB.loadAll<Api.Liberator.Error>()
46-
errorFiles.forEach { (key, error) ->
47-
try {
48-
apiClient.liberator.reportError(error)
49-
jsonDB.delete<Api.Liberator.Error>(key)
50-
log("Uploaded Api.Liberator.Error $key")
51-
} catch (e: Exception) {
52-
log("Failed to upload error", e)
53-
return Result.retry()
54-
}
55-
}
56+
val type = inputData.getString("type") ?: return Result.failure()
57+
val key = inputData.getString("key") ?: return Result.failure()
58+
59+
val apiBaseFromPreference by SharedPreferences.api_base
60+
val apiClient = ApiClient((apiBaseFromPreference.takeUnless { it == "" } ?: API_BASE).toHttpUrl())
5661

57-
val successFiles = jsonDB.loadAll<Api.Liberator.Success>()
58-
successFiles.forEach { (key, success) ->
59-
try {
60-
apiClient.liberator.reportSuccess(success)
61-
jsonDB.delete<Api.Liberator.Success>(key)
62-
log("Uploaded Api.Liberator.Success $key")
63-
} catch (e: Exception) {
64-
log("Failed to upload success", e)
65-
return Result.retry()
62+
try {
63+
when (type) {
64+
"har" -> {
65+
val har = jsonDB.load<HAR>(key, "har")
66+
apiClient.har.submitHar(key, har)
67+
jsonDB.delete<HAR>(key, "har")
68+
log("Uploaded HAR $key")
69+
}
70+
"error" -> {
71+
val error = jsonDB.load<Api.Liberator.Error>(key)
72+
apiClient.liberator.reportError(error)
73+
jsonDB.delete<Api.Liberator.Error>(key)
74+
log("Uploaded Api.Liberator.Error $key")
75+
}
76+
"success" -> {
77+
val success = jsonDB.load<Api.Liberator.Success>(key)
78+
apiClient.liberator.reportSuccess(success)
79+
jsonDB.delete<Api.Liberator.Success>(key)
80+
log("Uploaded Api.Liberator.Success $key")
81+
}
82+
}
83+
84+
return Result.success()
85+
} catch (e: FileNotFoundException) {
86+
log("Failed to upload $type $key", e)
87+
return Result.failure()
88+
} catch (e: HttpStatusCodeException) {
89+
when (e.code) {
90+
429 -> {
91+
val timeout = e.response.header("Retry-After")?.toLongOrNull() ?: 0
92+
log("Failed to upload $type $key: HTTP 429 - timeout: $timeout")
93+
recordRetry()
94+
delay(timeout.seconds)
95+
return Result.retry()
96+
}
97+
98+
500, 501, 502, 503, 504, 507 -> {
99+
log("Server error trying to upload $type $key: HTTP ${e.code}, trying again later", e)
100+
recordRetry()
101+
return Result.retry()
102+
}
103+
104+
else -> {
105+
log("Failed to upload $type $key: HTTP ${e.code}", e)
106+
return Result.failure()
107+
}
66108
}
109+
} catch (e: Exception) {
110+
log("Failed to upload $type $key", e)
111+
recordRetry()
112+
return Result.retry()
67113
}
68-
69-
log("StatsWorker finished")
70-
return Result.success()
71114
}
72115
}
73116

@@ -80,7 +123,7 @@ object Stats : Api {
80123
override fun submitHar(name: String, har: HAR) {
81124
val key = name
82125
jsonDB.store(key, har, "har")
83-
triggerUpload()
126+
scheduleUpload("har", key)
84127
}
85128
}
86129

@@ -96,21 +139,23 @@ object Stats : Api {
96139
override fun reportError(error: Api.Liberator.Error) {
97140
val key = "${System.currentTimeMillis()}_${error.hashCode()}"
98141
jsonDB.store(key, error)
99-
triggerUpload()
142+
scheduleUpload("error", key)
100143
}
101144

102145
override fun reportSuccess(success: Api.Liberator.Success) {
103146
val key = "${System.currentTimeMillis()}_${success.hashCode()}"
104147
jsonDB.store(key, success)
105-
triggerUpload()
148+
scheduleUpload("success", key)
106149
}
107150
}
108151

109-
// Schedule the WorkRequest
110-
fun triggerUpload() {
111-
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
112-
val uploadRequest = OneTimeWorkRequestBuilder<StatsWorker>().setConstraints(constraints).build()
152+
private fun scheduleUpload(type: String, key: String) {
153+
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()
154+
val inputData = androidx.work.workDataOf("type" to type, "key" to key)
155+
val uploadRequest =
156+
OneTimeWorkRequestBuilder<StatsWorker>().setConstraints(constraints).setInputData(inputData).build()
157+
113158
WorkManager.getInstance(applicationContext).enqueue(uploadRequest)
114-
log("Scheduled upload")
159+
log("Scheduled upload for $type: $key")
115160
}
116161
}

app/src/main/kotlin/de/binarynoise/captiveportalautologin/preferences/MainFragment.kt

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,15 @@ import androidx.preference.CheckBoxPreference
1010
import androidx.preference.DropDownPreference
1111
import androidx.preference.ListPreference
1212
import androidx.preference.Preference
13-
import androidx.preference.PreferenceManager
1413
import androidx.preference.SwitchPreference
1514
import de.binarynoise.captiveportalautologin.BuildConfig
1615
import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService
1716
import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService.NetworkState
1817
import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService.ServiceState
1918
import de.binarynoise.captiveportalautologin.GeckoViewActivity
2019
import de.binarynoise.captiveportalautologin.Permissions
21-
import de.binarynoise.captiveportalautologin.Stats
22-
import de.binarynoise.captiveportalautologin.util.applicationContext
2320
import de.binarynoise.captiveportalautologin.xposed.Xposed
2421
import de.binarynoise.liberator.PortalDetection
25-
import de.binarynoise.logger.Logger.log
2622
import okhttp3.HttpUrl.Companion.toHttpUrl
2723
import org.mozilla.gecko.util.ThreadUtils.runOnUiThread
2824

@@ -188,15 +184,6 @@ class MainFragment : AutoCleanupPreferenceFragment() {
188184
isEnabled = false
189185
}
190186

191-
addPreference(Preference(ctx)) {
192-
title = "Send Statistics now"
193-
194-
setOnPreferenceClickListener {
195-
Stats.triggerUpload()
196-
true
197-
}
198-
}
199-
200187
if (BuildConfig.DEBUG) {
201188
addPreference(EditTextPreference(ctx, SharedPreferences.api_base.get()) { editText, s ->
202189
if (s.isBlank()) {

app/src/main/kotlin/de/binarynoise/captiveportalautologin/preferences/SharedPreferences.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ object SharedPreferences {
1414
val liberator_send_stats by PreferenceProperty(true)
1515
val api_base by PreferenceProperty("")
1616

17+
val stats_last_retry_time by PreferenceProperty(0L)
18+
1719
private class PreferenceProperty<T>(private val defaultValue: T) {
1820
operator fun getValue(parent: Any, property: KProperty<*>): PreferencePropertyDelegate<T> {
1921
return PreferencePropertyDelegate(property, defaultValue)
@@ -31,16 +33,14 @@ class PreferencePropertyDelegate<T>(val parent: KProperty<*>, val defaultValue:
3133
}
3234

3335
@Suppress("UNCHECKED_CAST")
34-
operator fun getValue(parent: Nothing?, property: KProperty<*>?): T {
36+
operator fun getValue(parent: Any?, property: KProperty<*>?): T {
3537
with(PreferenceManager.getDefaultSharedPreferences(applicationContext)) {
3638
return if (contains(key)) all[key] as T
3739
else defaultValue
3840
}
3941
}
4042

41-
operator fun getValue(parent: Any, property: KProperty<*>?): T = getValue(null, null)
42-
43-
operator fun setValue(parent: Nothing?, property: KProperty<*>?, newValue: T) {
43+
operator fun setValue(parent: Any?, property: KProperty<*>?, newValue: T) {
4444
PreferenceManager.getDefaultSharedPreferences(applicationContext).edit {
4545
when (newValue) {
4646
null -> remove(key)

0 commit comments

Comments
 (0)