diff --git a/app/src/debug/kotlin/de/binarynoise/captiveportalautologin/QueuedWorkActivity.kt b/app/src/debug/kotlin/de/binarynoise/captiveportalautologin/QueuedWorkActivity.kt index 7fc6d2c..6d002e9 100644 --- a/app/src/debug/kotlin/de/binarynoise/captiveportalautologin/QueuedWorkActivity.kt +++ b/app/src/debug/kotlin/de/binarynoise/captiveportalautologin/QueuedWorkActivity.kt @@ -22,12 +22,12 @@ class QueuedWorkActivity : ComponentActivity() { setContentView(binding.root) binding.scheduleButton.setOnClickListener { - Stats.scheduleEverythingForUpload() + enqueueStatsUploadWork(singleShot = true) } lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - Stats.getScheduledWork().collect { workInfos -> + getEnqueuedStatsUploadWork().collect { workInfos -> binding.counters.text = buildString { append("Total: ") append(workInfos.size) diff --git a/app/src/debug/kotlin/de/binarynoise/captiveportalautologin/gecko/GeckoViewActivity.kt b/app/src/debug/kotlin/de/binarynoise/captiveportalautologin/gecko/GeckoViewActivity.kt index be72f2c..640f9bc 100644 --- a/app/src/debug/kotlin/de/binarynoise/captiveportalautologin/gecko/GeckoViewActivity.kt +++ b/app/src/debug/kotlin/de/binarynoise/captiveportalautologin/gecko/GeckoViewActivity.kt @@ -26,7 +26,7 @@ import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService.C import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService.NetworkState import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService.ServiceState import de.binarynoise.captiveportalautologin.R -import de.binarynoise.captiveportalautologin.Stats +import de.binarynoise.captiveportalautologin.ScheduledApiClient import de.binarynoise.captiveportalautologin.api.json.har.HAR import de.binarynoise.captiveportalautologin.client.toJson import de.binarynoise.captiveportalautologin.databinding.ActivityGeckoviewBinding @@ -248,7 +248,7 @@ class GeckoViewActivity : ComponentActivity() { R.id.action_upload -> { try { val (name, har) = createFinalizedHar() - Stats.har.submitHar(name, har) + ScheduledApiClient.har.submitHar(name, har) Toast.makeText(this, getString(R.string.submit_portal_scheduled), Toast.LENGTH_SHORT).show() } catch (e: Exception) { log("Error uploading file", e) diff --git a/app/src/main/kotlin/de/binarynoise/captiveportalautologin/Application.kt b/app/src/main/kotlin/de/binarynoise/captiveportalautologin/Application.kt index 38654be..d716a03 100644 --- a/app/src/main/kotlin/de/binarynoise/captiveportalautologin/Application.kt +++ b/app/src/main/kotlin/de/binarynoise/captiveportalautologin/Application.kt @@ -25,6 +25,8 @@ open class Application : android.app.Application() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && SharedPreferences.network_suggestions.get()) { enqueueUpdateNetworkSuggestionSSIDsWork(this) } + + enqueueStatsUploadWork(this) } /** diff --git a/app/src/main/kotlin/de/binarynoise/captiveportalautologin/ConnectivityChangeListenerService.kt b/app/src/main/kotlin/de/binarynoise/captiveportalautologin/ConnectivityChangeListenerService.kt index da4b58c..a17e2fc 100644 --- a/app/src/main/kotlin/de/binarynoise/captiveportalautologin/ConnectivityChangeListenerService.kt +++ b/app/src/main/kotlin/de/binarynoise/captiveportalautologin/ConnectivityChangeListenerService.kt @@ -391,7 +391,7 @@ class ConnectivityChangeListenerService : Service() { log("broke out of the portal") Toast.makeText(applicationContext, R.string.liberate_success, Toast.LENGTH_SHORT).show() reportNetworkConnectivity(network, true) - Stats.liberator.reportSuccess( + ScheduledApiClient.liberator.reportSuccess( Success( version = BuildConfig.VERSION_NAME, timestamp = System.currentTimeMillis(), @@ -410,7 +410,7 @@ class ConnectivityChangeListenerService : Service() { ).show() reportNetworkConnectivity(network, false) liberationResult.har.comment = ssid - Stats.liberator.reportError( + ScheduledApiClient.liberator.reportError( Error( version = BuildConfig.VERSION_NAME, timestamp = System.currentTimeMillis(), @@ -441,7 +441,7 @@ class ConnectivityChangeListenerService : Service() { Toast.LENGTH_SHORT, ).show() reportNetworkConnectivity(network, false) - Stats.liberator.reportError( + ScheduledApiClient.liberator.reportError( Error( version = BuildConfig.VERSION_NAME, timestamp = System.currentTimeMillis(), @@ -462,7 +462,7 @@ class ConnectivityChangeListenerService : Service() { Toast.LENGTH_SHORT, ).show() reportNetworkConnectivity(network, false) - Stats.liberator.reportError( + ScheduledApiClient.liberator.reportError( Error( version = BuildConfig.VERSION_NAME, timestamp = System.currentTimeMillis(), @@ -496,7 +496,7 @@ class ConnectivityChangeListenerService : Service() { Toast.LENGTH_LONG, ).show() reportNetworkConnectivity(network, false) - Stats.liberator.reportError( + ScheduledApiClient.liberator.reportError( Error( version = BuildConfig.VERSION_NAME, timestamp = System.currentTimeMillis(), diff --git a/app/src/main/kotlin/de/binarynoise/captiveportalautologin/ScheduledApiClient.kt b/app/src/main/kotlin/de/binarynoise/captiveportalautologin/ScheduledApiClient.kt new file mode 100644 index 0000000..8060369 --- /dev/null +++ b/app/src/main/kotlin/de/binarynoise/captiveportalautologin/ScheduledApiClient.kt @@ -0,0 +1,228 @@ +package de.binarynoise.captiveportalautologin + +import java.util.concurrent.TimeUnit +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant +import kotlinx.coroutines.flow.Flow +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import de.binarynoise.captiveportalautologin.BuildConfig.API_BASE +import de.binarynoise.captiveportalautologin.api.Api +import de.binarynoise.captiveportalautologin.api.json.har.HAR +import de.binarynoise.captiveportalautologin.client.ApiClient +import de.binarynoise.captiveportalautologin.preferences.SharedPreferences +import de.binarynoise.captiveportalautologin.util.applicationContext +import de.binarynoise.filedb.JsonDB +import de.binarynoise.logger.Logger.log +import de.binarynoise.util.okhttp.HttpStatusCodeException +import de.binarynoise.util.okhttp.parseRetryAfterOrNull +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +private val localCacheRoot = applicationContext.cacheDir.toPath().resolve("Stats") +private val jsonDB = JsonDB(localCacheRoot) + + +object ScheduledApiClient : Api { + override val har: Har = Har() + override val liberator: Liberator = Liberator() + + class Har : Api.Har { + override fun submitHar(name: String, har: HAR) { + jsonDB.store(name, har, "har") + enqueueStatsUploadWork() + } + } + + class Liberator : Api.Liberator { + override fun getLiberatorVersion(): String { + TODO("Not yet implemented") + } + + override fun fetchLiberatorUpdate() { + TODO("Not yet implemented") + } + + override fun reportError(error: Api.Liberator.Error) { + val key = "${System.currentTimeMillis()}_${error.hashCode()}" + jsonDB.store(key, error) + enqueueStatsUploadWork() + } + + override fun reportSuccess(success: Api.Liberator.Success) { + val key = "${System.currentTimeMillis()}_${success.hashCode()}" + jsonDB.store(key, success) + enqueueStatsUploadWork() + } + } + + @Deprecated("use ApiClient directly", level = DeprecationLevel.HIDDEN) + override suspend fun getSSIDs( + limit: Int?, majorVersion: Int?, since: Instant?, minimum: Int?, + ): List { + throw UnsupportedOperationException("use ApiClient directly") + } +} + +class HarStatsWorker( + appContext: Context, + workerParams: WorkerParameters, +) : StatsWorker( + appContext, + workerParams, + type = "HAR", + keys = { jsonDB.listAll("har") }, + load = { jsonDB.load(it, "har") }, + upload = { key, har, apiClient -> apiClient.har.submitHar(key, har) }, + delete = { jsonDB.delete(it, "har") }, +) + +class SuccessStatsWorker( + appContext: Context, + workerParams: WorkerParameters, +) : StatsWorker( + appContext, + workerParams, + type = "Success", + keys = { jsonDB.listAll() }, + load = { jsonDB.load(it) }, + upload = { key, success, apiClient -> apiClient.liberator.reportSuccess(success) }, + delete = { jsonDB.delete(it) }, +) + +class ErrorStatsWorker( + appContext: Context, + workerParams: WorkerParameters, +) : StatsWorker( + appContext, + workerParams, + type = "Error", + keys = { jsonDB.listAll() }, + load = { jsonDB.load(it) }, + upload = { key, error, apiClient -> apiClient.liberator.reportError(error) }, + delete = { jsonDB.delete(it) }, +) + + +abstract class StatsWorker( + appContext: Context, + workerParams: WorkerParameters, + val type: String, + val keys: () -> Collection, + val load: (key: String) -> T, + val upload: (key: String, T, ApiClient) -> Unit, + val delete: (key: String) -> Unit, +) : CoroutineWorker(appContext, workerParams) { + + private val retryStatusCodes = arrayOf(429, 500, 502, 503, 504, 506, 507) + + override suspend fun doWork(): Result { + var savedRetryAfter by SharedPreferences.stats_retry_after(type) + val now = Clock.System.now() + if (savedRetryAfter > now && !inputData.getBoolean("skipDelay", false)) { + log("Skipping $type upload - retry after ${savedRetryAfter - now}") + return Result.retry() + } + + val apiBaseFromPreference by SharedPreferences.api_base + val apiBaseUrl = + (apiBaseFromPreference.takeUnless { it == "" } ?: API_BASE).toHttpUrlOrNull() ?: return Result.failure() + val apiClient = ApiClient(apiBaseUrl) + + var shouldRetry = false + + + for (key in keys()) { + log("Uploading $type $key") + try { + val item = load(key) + upload(key, item, apiClient) + delete(key) + log("Uploaded $type $key") + } catch (e: HttpStatusCodeException) { + log("Failed to upload $type $key: HTTP ${e.code}") + if (e.code in retryStatusCodes) { + shouldRetry = true + val now = Clock.System.now() + savedRetryAfter = now + (parseRetryAfterOrNull(e.response.headers) ?: 5.minutes) + break + } else { + delete(key) + } + } catch (e: Exception) { + log("Failed to upload $type $key", e) + shouldRetry = true + val now = Clock.System.now() + savedRetryAfter = now + 5.minutes + break + } + } + + return if (shouldRetry) Result.retry() else Result.success() + } +} + +private val statsWorkerClasses = listOf( + HarStatsWorker::class.java, + SuccessStatsWorker::class.java, + ErrorStatsWorker::class.java, +) + +private fun getStatsUploadUniqueWorkName(cls: Class>): String = "StatsUpload ${cls.simpleName}" + +private val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build() + +fun enqueueStatsUploadWork( + context: Context = applicationContext, + singleShot: Boolean = false, +) { + val workManager = WorkManager.getInstance(context) + if (singleShot) { + log("enqueue expedited stats upload work") + val inputData = workDataOf("skipDelay" to true) + statsWorkerClasses.forEach { + val workRequest = OneTimeWorkRequest.Builder(it).apply { + setConstraints(constraints) + addTag(StatsWorker::class.java.name) + setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) + setInputData(inputData) + }.build() + workManager.enqueue(workRequest) + } + } else { + log("enqueuing periodic stats upload work") + + statsWorkerClasses.forEach { + val workRequest = PeriodicWorkRequest.Builder(it, 1, TimeUnit.HOURS).apply { + setConstraints(constraints) + addTag(StatsWorker::class.java.name) + }.build() + workManager.enqueueUniquePeriodicWork( + getStatsUploadUniqueWorkName(it), + ExistingPeriodicWorkPolicy.KEEP, + workRequest, + ) + } + } +} + +fun dequeueStatsUploadWork(context: Context = applicationContext) { + val workManager = WorkManager.getInstance(context) + statsWorkerClasses.forEach { + workManager.cancelUniqueWork(getStatsUploadUniqueWorkName(it)) + } +} + +fun getEnqueuedStatsUploadWork(context: Context = applicationContext): Flow> { + return WorkManager.getInstance(context).getWorkInfosByTagFlow(StatsWorker::class.java.name) +} diff --git a/app/src/main/kotlin/de/binarynoise/captiveportalautologin/Stats.kt b/app/src/main/kotlin/de/binarynoise/captiveportalautologin/Stats.kt deleted file mode 100644 index f22bc47..0000000 --- a/app/src/main/kotlin/de/binarynoise/captiveportalautologin/Stats.kt +++ /dev/null @@ -1,193 +0,0 @@ -package de.binarynoise.captiveportalautologin - -import java.io.FileNotFoundException -import kotlin.time.Clock -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import android.content.Context -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import de.binarynoise.captiveportalautologin.BuildConfig.API_BASE -import de.binarynoise.captiveportalautologin.api.Api -import de.binarynoise.captiveportalautologin.api.json.har.HAR -import de.binarynoise.captiveportalautologin.client.ApiClient -import de.binarynoise.captiveportalautologin.preferences.SharedPreferences -import de.binarynoise.captiveportalautologin.util.applicationContext -import de.binarynoise.filedb.JsonDB -import de.binarynoise.logger.Logger.log -import de.binarynoise.util.okhttp.HttpStatusCodeException -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull - -private val localCacheRoot = applicationContext.cacheDir.toPath().resolve("Stats") -private val jsonDB = JsonDB(localCacheRoot) - -// Worker class to handle the upload -class StatsWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { - - var lastRetryTime by SharedPreferences.stats_last_retry_time - private val retryCooldown = 5.minutes - - private fun hasRecentRetry(): Boolean { - return (Clock.System.now() - Instant.fromEpochMilliseconds(lastRetryTime)) < retryCooldown - } - - private fun recordRetry() { - lastRetryTime = Clock.System.now().toEpochMilliseconds() - } - - override suspend fun doWork(): Result { - log("StatsWorker started") - - if (hasRecentRetry()) { - log("Recent retry detected, delaying this attempt to avoid spamming") - delay(retryCooldown) - } - - val type = inputData.getString("type") ?: return Result.failure() - val key = inputData.getString("key") ?: return Result.failure() - - val apiBaseFromPreference by SharedPreferences.api_base - val apiBaseUrl = (apiBaseFromPreference.takeUnless { it == "" } ?: API_BASE).toHttpUrlOrNull() - if (apiBaseUrl == null) return Result.failure() - val apiClient = ApiClient(apiBaseUrl) - - try { - when (type) { - "har" -> { - val har = jsonDB.load(key, "har") - apiClient.har.submitHar(key, har) - jsonDB.delete(key, "har") - log("Uploaded HAR $key") - } - "error" -> { - val error = jsonDB.load(key) - apiClient.liberator.reportError(error) - jsonDB.delete(key) - log("Uploaded Api.Liberator.Error $key") - } - "success" -> { - val success = jsonDB.load(key) - apiClient.liberator.reportSuccess(success) - jsonDB.delete(key) - log("Uploaded Api.Liberator.Success $key") - } - } - - return Result.success() - } catch (e: FileNotFoundException) { - log("Failed to upload $type $key", e) - return Result.failure() - } catch (e: HttpStatusCodeException) { - when (e.code) { - 429 -> { - val timeout = e.response.header("Retry-After")?.toLongOrNull() ?: 0 - log("Failed to upload $type $key: HTTP 429 - timeout: $timeout") - recordRetry() - delay(timeout.seconds) - return Result.retry() - } - - 500, 501, 502, 503, 504, 507 -> { - log("Server error trying to upload $type $key: HTTP ${e.code}, trying again later", e) - recordRetry() - return Result.retry() - } - - else -> { - log("Failed to upload $type $key: HTTP ${e.code}", e) - return Result.failure() - } - } - } catch (e: Exception) { - log("Failed to upload $type $key", e) - recordRetry() - return Result.retry() - } - } -} - -object Stats : Api { - override val har: Har = Har() - override val liberator: Liberator = Liberator() - - class Har : Api.Har { - override fun submitHar(name: String, har: HAR) { - val key = name - jsonDB.store(key, har, "har") - scheduleUpload("har", key) - } - } - - class Liberator : Api.Liberator { - override fun getLiberatorVersion(): String { - TODO("Not yet implemented") - } - - override fun fetchLiberatorUpdate() { - TODO("Not yet implemented") - } - - override fun reportError(error: Api.Liberator.Error) { - val key = "${System.currentTimeMillis()}_${error.hashCode()}" - jsonDB.store(key, error) - scheduleUpload("error", key) - } - - override fun reportSuccess(success: Api.Liberator.Success) { - val key = "${System.currentTimeMillis()}_${success.hashCode()}" - jsonDB.store(key, success) - scheduleUpload("success", key) - } - } - - private fun scheduleUpload(type: String, key: String) { - val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build() - val inputData = androidx.work.workDataOf("type" to type, "key" to key) - val uploadRequest = - OneTimeWorkRequestBuilder().setConstraints(constraints).setInputData(inputData).build() - - WorkManager.getInstance(applicationContext).enqueue(uploadRequest) - log("Scheduled upload for $type: $key") - } - - fun getScheduledWork(): Flow> { - return WorkManager.getInstance(applicationContext).getWorkInfosByTagFlow(StatsWorker::class.java.name) - } - - fun scheduleEverythingForUpload() { - try { - val harKeys = jsonDB.listAll("har") - harKeys.forEach { key -> - scheduleUpload("har", key) - } - - val errorKeys = jsonDB.listAll() - errorKeys.forEach { key -> - scheduleUpload("error", key) - } - - val successKeys = jsonDB.listAll() - successKeys.forEach { key -> - scheduleUpload("success", key) - } - - log("Scheduled upload for ${harKeys.size} HAR files, ${errorKeys.size} error reports, ${successKeys.size} success reports") - } catch (e: Exception) { - log("Failed to schedule everything for upload", e) - } - } - - override suspend fun getSSIDs( - limit: Int?, majorVersion: Int?, since: Instant?, minimum: Int? - ): List { - throw NotImplementedError("use apiclient directly") - } -} diff --git a/app/src/main/kotlin/de/binarynoise/captiveportalautologin/gecko/RecordCaptivePortalActivity.kt b/app/src/main/kotlin/de/binarynoise/captiveportalautologin/gecko/RecordCaptivePortalActivity.kt index 23e4fd2..de0fd83 100644 --- a/app/src/main/kotlin/de/binarynoise/captiveportalautologin/gecko/RecordCaptivePortalActivity.kt +++ b/app/src/main/kotlin/de/binarynoise/captiveportalautologin/gecko/RecordCaptivePortalActivity.kt @@ -27,7 +27,7 @@ import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService.C import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService.Companion.networkState import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService.Companion.networkStateLock import de.binarynoise.captiveportalautologin.R -import de.binarynoise.captiveportalautologin.Stats +import de.binarynoise.captiveportalautologin.ScheduledApiClient import de.binarynoise.captiveportalautologin.api.json.har.HAR import de.binarynoise.captiveportalautologin.databinding.ActivityRecordCaptivePortalBinding import de.binarynoise.captiveportalautologin.preferences.SharedPreferences @@ -200,7 +200,7 @@ class RecordCaptivePortalActivity : ComponentActivity() { .setMessage(getString(R.string.submit_portal_description)) .setPositiveButton(android.R.string.yes) { _, _ -> val (name, har) = createFinalizedHar() - Stats.har.submitHar(name, har) + ScheduledApiClient.har.submitHar(name, har) Toast.makeText(this, R.string.submit_portal_confirmed, Toast.LENGTH_SHORT).show() } .setNegativeButton(android.R.string.no) { _, _ -> diff --git a/app/src/main/kotlin/de/binarynoise/captiveportalautologin/preferences/SharedPreferences.kt b/app/src/main/kotlin/de/binarynoise/captiveportalautologin/preferences/SharedPreferences.kt index 450797b..6ee95bb 100644 --- a/app/src/main/kotlin/de/binarynoise/captiveportalautologin/preferences/SharedPreferences.kt +++ b/app/src/main/kotlin/de/binarynoise/captiveportalautologin/preferences/SharedPreferences.kt @@ -3,10 +3,9 @@ package de.binarynoise.captiveportalautologin.preferences import kotlin.properties.ReadOnlyProperty import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty -import android.content.Context +import kotlin.time.Instant import android.provider.Settings import androidx.core.content.edit -import androidx.preference.DropDownPreference import androidx.preference.ListPreference import androidx.preference.PreferenceManager import de.binarynoise.captiveportalautologin.BuildConfig @@ -61,7 +60,12 @@ object SharedPreferences { val liberator_experimental_enabled_sharedPreferencesKey: String get() = liberator_experimental.sharedPreferencesKey - val stats_last_retry_time by PreferenceProperty(0L) + fun stats_retry_after(type: String): TypeConversionPreferencePropertyDelegate = + TypeConversionPreferencePropertyDelegate( + { wrapped: Long -> Instant.fromEpochMilliseconds(wrapped) }, + { toStore: Instant -> toStore.toEpochMilliseconds() }, + PreferencePropertyDelegate("stats_retry_after_$type", 0L) + ) private class PreferenceProperty(private val defaultValue: T) { operator fun getValue(parent: Any, property: KProperty<*>): PreferencePropertyDelegate { @@ -152,3 +156,33 @@ class MappedPreferencePropertyDelegate( preference.setDefaultValue(defaultKey) } } + +class TypeConversionPreferencePropertyDelegate( + val unwrap: (W) -> V, + val wrap: (V) -> W, + val wrapped: PreferencePropertyDelegate, +) : ReadWriteProperty { + + val sharedPreferencesKey get() = wrapped.sharedPreferencesKey + val defaultValue get() = unwrap(wrapped.defaultValue) + + @JvmName("getValueNullable") + operator fun getValue(thisRef: Any?, property: KProperty<*>?): V { + return unwrap(wrapped.get()) + } + + override operator fun getValue(thisRef: Any?, property: KProperty<*>): V { + return getValue(thisRef, null) + } + + @JvmName("setValueNullable") + operator fun setValue(thisRef: Any?, property: KProperty<*>?, newValue: V) { + wrapped.setValue(thisRef, property, wrap(newValue)) + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: V) { + setValue(thisRef, null, value) + } + + fun get(): V = getValue(null, null) +} diff --git a/util/okhttp-kts/build.gradle.kts b/util/okhttp-kts/build.gradle.kts index 4fab9ef..9cf89fe 100644 --- a/util/okhttp-kts/build.gradle.kts +++ b/util/okhttp-kts/build.gradle.kts @@ -11,5 +11,7 @@ dependencies { implementation(libs.okhttp) implementation(libs.okhttp.urlconnection) + compileOnly(libs.kotlinx.datetime) + testImplementation(kotlin("test")) } diff --git a/util/okhttp-kts/src/main/kotlin/Response.kt b/util/okhttp-kts/src/main/kotlin/Response.kt index 2093304..06c3098 100644 --- a/util/okhttp-kts/src/main/kotlin/Response.kt +++ b/util/okhttp-kts/src/main/kotlin/Response.kt @@ -4,11 +4,17 @@ import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit +import kotlin.text.toLongOrNull +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.datetime.format.DateTimeComponents import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import de.binarynoise.logger.Logger.log import de.binarynoise.util.json.JsonArray import de.binarynoise.util.json.JsonObject +import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Protocol @@ -210,3 +216,20 @@ fun Response.submitOnlyForm( val baseUrl = this.requestUrl return html.submitOnlyForm(client, baseUrl, cssQuery, parameters, queryParameters, preConnectSetup) } + +fun parseRetryAfterOrNull(headers: Headers): Duration? { + val retryAfter = headers["Retry-After"] ?: return null + val seconds = retryAfter.toLongOrNull() + if (seconds != null) { + return seconds.seconds + } + + try { + val retryAt = DateTimeComponents.Formats.RFC_1123.parse(retryAfter).toInstantUsingOffset() + val now = Clock.System.now() + return if (retryAt > now) retryAt - now else null + } catch (_: Exception) { + log("Failed to parse Retry-After: $retryAfter") + } + return null +}