fix: use duration and exponential backoff

This commit is contained in:
Jannis Mattheis
2026-02-13 20:07:53 +01:00
parent 261e5821bb
commit 9a43db0902
5 changed files with 71 additions and 86 deletions

View File

@@ -12,6 +12,10 @@ import com.github.gotify.client.model.Message
import java.util.Calendar
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.pow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -25,8 +29,8 @@ internal class WebSocketConnection(
settings: SSLSettings,
private val token: String?,
private val alarmManager: AlarmManager,
private val reconnectInterval: Int,
private val disableBackoff: Boolean
private val reconnectDelay: Duration,
private val exponentialBackoff: Boolean
) {
companion object {
private val ID = AtomicLong(0)
@@ -130,19 +134,19 @@ internal class WebSocketConnection(
state = State.Disconnected
}
fun scheduleReconnectNow(seconds: Long) = scheduleReconnect(ID.get(), seconds)
fun scheduleReconnectNow(scheduleIn: Duration) = scheduleReconnect(ID.get(), scheduleIn)
@Synchronized
fun scheduleReconnect(id: Long, seconds: Long) {
fun scheduleReconnect(id: Long, scheduleIn: Duration) {
if (state == State.Connecting || state == State.Connected) {
return
}
state = State.Scheduled
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Logger.info("WebSocket: scheduling a restart in $seconds second(s) (via alarm manager)")
Logger.info("WebSocket: scheduling a restart in $scheduleIn (via alarm manager)")
val future = Calendar.getInstance()
future.add(Calendar.SECOND, seconds.toInt())
future.add(Calendar.SECOND, scheduleIn.inWholeSeconds.toInt())
alarmManagerCallback?.run(alarmManager::cancel)
val cb = OnAlarmListener { syncExec(id) { start() } }
@@ -155,11 +159,11 @@ internal class WebSocketConnection(
null
)
} else {
Logger.info("WebSocket: scheduling a restart in $seconds second(s)")
Logger.info("WebSocket: scheduling a restart in $scheduleIn")
handlerCallback?.run(reconnectHandler::removeCallbacks)
val cb = Runnable { syncExec(id) { start() } }
handlerCallback = cb
reconnectHandler.postDelayed(cb, TimeUnit.SECONDS.toMillis(seconds))
reconnectHandler.postDelayed(cb, scheduleIn.inWholeMilliseconds)
}
}
@@ -206,16 +210,15 @@ internal class WebSocketConnection(
closed()
errorCount++
val seconds = if (disableBackoff) {
reconnectInterval
} else {
((errorCount * 2 - 1) * reconnectInterval)
.coerceAtMost(TimeUnit.MINUTES.toSeconds(20).toInt())
}
val rounded = seconds.coerceAtLeast(5)
onFailure.execute(response?.message ?: "unreachable", rounded)
scheduleReconnect(id, rounded.toLong())
var scheduleIn = reconnectDelay
if (exponentialBackoff) {
scheduleIn *= 2.0.pow(errorCount - 1)
}
scheduleIn = scheduleIn.coerceIn(5.seconds..20.minutes)
onFailure.execute(response?.message ?: "unreachable", scheduleIn)
scheduleReconnect(id, scheduleIn)
}
super.onFailure(webSocket, t, response)
}
@@ -229,7 +232,7 @@ internal class WebSocketConnection(
}
internal fun interface OnNetworkFailureRunnable {
fun execute(status: String, seconds: Int)
fun execute(status: String, reconnectIn: Duration)
}
internal enum class State {

View File

@@ -40,6 +40,11 @@ import com.github.gotify.messages.MessagesActivity
import io.noties.markwon.Markwon
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
import kotlin.time.toDuration
import org.tinylog.kotlin.Logger
internal class WebSocketService : Service() {
@@ -111,16 +116,15 @@ internal class WebSocketService : Service() {
val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
var reconnectInterval = sharedPreferences
.getString(getString(R.string.setting_key_reconnect_interval), "60")
?.trim()
?.toIntOrNull() ?: 60
val reconnectDelay =
sharedPreferences.getString(
getString(R.string.setting_key_reconnect_delay),
null
)?.toIntOrNull()?.toDuration(DurationUnit.SECONDS) ?: 1.minutes
reconnectInterval = reconnectInterval.coerceIn(5, 60)
val disableBackoff = sharedPreferences.getBoolean(
getString(R.string.setting_key_backoff),
false
val exponentialBackoff = sharedPreferences.getBoolean(
getString(R.string.setting_key_exponential_backoff),
true
)
connection = WebSocketConnection(
@@ -128,12 +132,12 @@ internal class WebSocketService : Service() {
settings.sslSettings(),
settings.token,
alarmManager,
reconnectInterval,
disableBackoff
reconnectDelay,
exponentialBackoff
)
.onOpen { onOpen() }
.onClose { onClose() }
.onFailure { status, seconds -> onFailure(status, seconds) }
.onFailure { status, reconnectIn -> onFailure(status, reconnectIn) }
.onMessage { message -> onMessage(message) }
.onReconnected { notifyMissedNotifications() }
.start()
@@ -194,24 +198,14 @@ internal class WebSocketService : Service() {
}
private fun doReconnect() {
connection?.scheduleReconnectNow(15)
connection?.scheduleReconnectNow(15.seconds)
}
private fun onFailure(status: String, seconds: Int) {
private fun onFailure(status: String, reconnectIn: Duration) {
val title = getString(R.string.websocket_error, status)
val intervalUnit = if (seconds >= 60) {
val minutes = seconds / 60
resources.getQuantityString(R.plurals.websocket_retry_interval, minutes, minutes)
} else {
resources.getQuantityString(
R.plurals.websocket_retry_interval_seconds,
seconds,
seconds
)
}
showForegroundNotification(
title,
"${getString(R.string.websocket_reconnect)} $intervalUnit"
getString(R.string.websocket_reconnect, reconnectIn.toString())
)
}

View File

@@ -21,6 +21,7 @@ import androidx.preference.SwitchPreferenceCompat
import com.github.gotify.R
import com.github.gotify.Utils
import com.github.gotify.databinding.SettingsActivityBinding
import com.github.gotify.service.WebSocketService
import com.google.android.material.dialog.MaterialAlertDialogBuilder
internal class SettingsActivity :
@@ -75,28 +76,23 @@ internal class SettingsActivity :
)?.isEnabled = true
}
findPreference<androidx.preference.EditTextPreference>(
getString(R.string.setting_key_reconnect_interval)
)?.let {
it.setOnBindEditTextListener { editText ->
editText.inputType = android.text.InputType.TYPE_CLASS_NUMBER
}
it.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, newValue ->
val value = (newValue as String).trim().toIntOrNull() ?: 60
if (value < 5 || value > 60) {
Utils.showSnackBar(
requireActivity(),
"Please enter a value between 5 and 60"
)
return@OnPreferenceChangeListener false
}
requestWebSocketRestart()
true
getString(R.string.setting_key_reconnect_delay)
)?.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, newValue ->
val value = (newValue as String).trim().toIntOrNull() ?: 60
if (value !in 5..1200) {
Utils.showSnackBar(
requireActivity(),
"Please enter a value between 5 and 1200"
)
return@OnPreferenceChangeListener false
}
}
requestWebSocketRestart()
true
}
findPreference<SwitchPreferenceCompat>(
getString(R.string.setting_key_backoff)
getString(R.string.setting_key_exponential_backoff)
)?.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ ->
requestWebSocketRestart()
@@ -105,8 +101,7 @@ internal class SettingsActivity :
}
private fun requestWebSocketRestart() {
val intent =
Intent(requireContext(), com.github.gotify.service.WebSocketService::class.java)
val intent = Intent(requireContext(), WebSocketService::class.java)
requireContext().startService(intent)
}

View File

@@ -115,22 +115,14 @@
<string name="action_dialog_button_cancel">Cancel</string>
<string name="websocket_error">Error %s (see logs)</string>
<string name="websocket_reconnect">Trying to reconnect</string>
<plurals name="websocket_retry_interval">
<item quantity="one">in %d minute</item>
<item quantity="other">in %d minutes</item>
</plurals>
<plurals name="websocket_retry_interval_seconds">
<item quantity="one">in %d second</item>
<item quantity="other">in %d seconds</item>
</plurals>
<string name="setting_reconnect_interval">Reconnect Interval (Seconds)</string>
<string name="setting_key_reconnect_interval">reconnect_interval</string>
<string name="websocket_reconnect">Trying to reconnect in %s</string>
<string name="setting_reconnect_delay">Reconnect Delay (Seconds)</string>
<string name="setting_key_reconnect_delay">reconnect_delay</string>
<string name="setting_connection">Connection</string>
<string name="setting_reconnect_interval_summary">Wait time between connection attempts</string>
<string name="setting_backoff_title">Constant Retry Interval</string>
<string name="setting_backoff_summary">Do not increase wait time between retries</string>
<string name="setting_key_backoff">reconnect_backoff</string>
<string name="setting_reconnect_interval_summary">Delay between reconnect attempts</string>
<string name="setting_exponential_backoff_title">Exponential Backoff</string>
<string name="setting_exponential_backoff_summary">Exponentially increase the reconnect delay for each reconnect attempt</string>
<string name="setting_key_exponential_backoff">reconnect_exponential_backoff</string>
<string name="notification_channel_title_foreground">Gotify foreground notification</string>
<string name="notification_channel_title_min">Min priority messages (&lt;1)</string>

View File

@@ -54,14 +54,15 @@
<PreferenceCategory app:title="@string/setting_connection">
<EditTextPreference
android:defaultValue="60"
android:key="@string/setting_key_reconnect_interval"
android:title="@string/setting_reconnect_interval"
android:inputType="numberSigned"
android:key="@string/setting_key_reconnect_delay"
android:title="@string/setting_reconnect_delay"
android:summary="@string/setting_reconnect_interval_summary" />
<SwitchPreferenceCompat
android:key="@string/setting_key_backoff"
android:title="@string/setting_backoff_title"
android:summary="@string/setting_backoff_summary"
android:defaultValue="false" />
android:key="@string/setting_key_exponential_backoff"
android:title="@string/setting_exponential_backoff_title"
android:summary="@string/setting_exponential_backoff_summary"
android:defaultValue="true" />
</PreferenceCategory>
</PreferenceScreen>