feat: optimize network stack (#404)

* feat: optimize network stack and connection management

Refactor OkHttp connection pools, consolidate Gson serialization, add network-aware WebSocket reconnects, and deduplicate concurrent requests.

* test: update mapping assertions for AuthExpired

* refactor: address PR review feedback on network stack

Simplify health check, improve authenticator cookie matching robustness, resolve nit imports, and add unit tests.
This commit is contained in:
M57 authored and GitHub committed 2026-06-27 00:45:48 +03:00
1 parent 9344c3bd6e
commit 85c4662537
19 files changed
+455 -206

No files matched your search

@@ -4,11 +4,9 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.CertificatePinner
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Provides a Retrofit-backed [HermesApiService].
@@ -62,12 +60,10 @@ object ApiClient {
}
val tempOkHttp =
OkHttpClient
.Builder()
OkHttpProvider
.base
.newBuilder()
.addInterceptor(tempAuthInterceptor)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
val tempRetrofit =
@@ -75,7 +71,7 @@ object ApiClient {
.Builder()
.baseUrl("http://$host:$port/")
.client(tempOkHttp)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(OkHttpProvider.gson))
.build()
return tempRetrofit.create(HermesApiService::class.java)
@@ -84,10 +80,6 @@ object ApiClient {
// ── Internal ─────────────────────────────────────────────────────────
private fun buildService(): HermesApiService {
// B1 (Jun 18 2026, kanban t_afc1d26f): Level.BODY prints every request —
// including the Authorization: Bearer header from authInterceptor below —
// to logcat in plaintext. Gate on BuildConfig.DEBUG so release builds
// are silent; debug builds behave identically to before.
val logging =
HttpLoggingInterceptor().apply {
level =
@@ -126,20 +118,16 @@ object ApiClient {
}
}
// SEC-11: No certificate pinning is configured by default since the app
// intentionally uses HTTP for LAN and hosts are dynamic.
// CertificatePinner infrastructure is provided here if HTTPS is used with a known host.
val certificatePinner = CertificatePinner.Builder().build()
val okHttp =
OkHttpClient
.Builder()
OkHttpProvider
.base
.newBuilder()
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.authenticator(TokenRefreshAuthenticator)
.certificatePinner(certificatePinner)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
val rf =
@@ -147,7 +135,7 @@ object ApiClient {
.Builder()
.baseUrl(AuthManager.baseUrl())
.client(okHttp)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(OkHttpProvider.gson))
.build()
.also { retrofit = it }
@@ -0,0 +1,29 @@
package com.m57.hermescontrol.data.remote
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object NetworkMonitor {
private val _isConnected = MutableStateFlow(true) // optimistic default
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
fun init(context: Context) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
_isConnected.value = cm.activeNetwork != null
cm.registerDefaultNetworkCallback(
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_isConnected.value = true
}
override fun onLost(network: Network) {
_isConnected.value = false
}
},
)
}
}
@@ -27,6 +27,10 @@ sealed interface NetworkError {
val cause: IOException,
) : NetworkError
data class AuthExpired(
override val message: String = "Authentication token has expired. Please log in again.",
) : NetworkError
data class Unknown(
override val message: String,
val cause: Throwable,
@@ -34,10 +38,12 @@ sealed interface NetworkError {
}
fun mapHttpError(code: Int): NetworkError {
if (code == 401) {
return NetworkError.AuthExpired()
}
val message =
when (code) {
400 -> "Bad Request (HTTP 400): The server could not understand the request."
401 -> "Unauthorized (HTTP 401): Authentication token is invalid or expired."
403 -> "Forbidden (HTTP 403): You do not have permission to access this resource."
404 -> "Not Found (HTTP 404): The requested resource could not be found."
405 -> "Method Not Allowed (HTTP 405)."
@@ -50,8 +56,22 @@ fun mapHttpError(code: Int): NetworkError {
return NetworkError.Http(code, message)
}
fun isRetryable(e: IOException): Boolean =
when (e) {
is java.net.UnknownHostException -> false
is javax.net.ssl.SSLException -> false
is java.net.ConnectException -> true
is java.net.SocketTimeoutException -> true
else -> true
}
fun jitteredBackoff(baseMs: Long): Long {
val jitter = (baseMs * 0.25 * (Math.random() * 2 - 1)).toLong()
return baseMs + jitter
}
suspend inline fun <reified T> safeApiCall(
retries: Int = 3,
retries: Int = 2,
crossinline call: suspend () -> Response<T>,
): NetworkResult<T> {
var lastException: IOException? = null
@@ -68,8 +88,8 @@ suspend inline fun <reified T> safeApiCall(
}
} else {
val code = response.code()
if (code in 500..599 && attempt < retries) {
val backoff = 500L * (1 shl attempt)
if ((code in 500..599 || code == 429) && attempt < retries) {
val backoff = jitteredBackoff(500L * (1 shl attempt))
delay(backoff)
continue
}
@@ -77,7 +97,7 @@ suspend inline fun <reified T> safeApiCall(
}
} catch (e: IOException) {
lastException = e
if (attempt == retries) {
if (attempt == retries || !isRetryable(e)) {
return NetworkResult.Failure(
NetworkError.Connection(
"Network connection failure: ${e.message ?: "Unknown connection error"}",
@@ -85,7 +105,7 @@ suspend inline fun <reified T> safeApiCall(
),
)
}
val backoff = 500L * (1 shl attempt)
val backoff = jitteredBackoff(500L * (1 shl attempt))
delay(backoff)
} catch (e: Exception) {
return NetworkResult.Failure(
@@ -0,0 +1,47 @@
package com.m57.hermescontrol.data.remote
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import okhttp3.ConnectionPool
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
object OkHttpProvider {
// Single connection pool shared by ALL clients (REST, WS, probes)
// 5 idle connections, 30s keep-alive — tuned for single-server LAN
private val connectionPool = ConnectionPool(5, 30, TimeUnit.SECONDS)
// Base client: connection pool + sensible defaults
// Note: retryOnConnectionFailure(true) is enabled to allow OkHttp to recover from
// low-level connection/route failures (e.g. route timeouts, IPv4/IPv6 fallback). This is
// separate from safeApiCall's app-level retries (which add backoff delays and handle 5xx/429/timeouts).
val base: OkHttpClient =
OkHttpClient
.Builder()
.connectionPool(connectionPool)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
// WebSocket-optimized variant (infinite read timeout, ping interval)
val websocket: OkHttpClient =
base
.newBuilder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(30, TimeUnit.SECONDS)
.build()
// Short-timeout variant for probes and ticket minting
val probe: OkHttpClient =
base
.newBuilder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.followRedirects(false)
.build()
// Shared Gson — thread-safe, zero-config
val gson: Gson = GsonBuilder().create()
}
@@ -0,0 +1,40 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
object TokenRefreshAuthenticator : Authenticator {
override fun authenticate(
route: Route?,
response: Response,
): Request? {
if (response.priorResponse != null) {
return null // Only retry once
}
val sessionCookie = AuthManager.getSessionCookie()
if (!sessionCookie.isNullOrBlank()) {
val requestCookie = response.request.header("Cookie")
val currentCookieHeader = "hermes_session_at=$sessionCookie"
if (requestCookie == null || !requestCookie.contains(currentCookieHeader)) {
return response.request
.newBuilder()
.header("Cookie", currentCookieHeader)
.build()
}
} else {
val token = AuthManager.getToken()
val requestAuth = response.request.header("Authorization")
val currentAuthHeader = "Bearer $token"
if (!token.isNullOrBlank() && (requestAuth == null || !requestAuth.contains(currentAuthHeader))) {
return response.request
.newBuilder()
.header("Authorization", currentAuthHeader)
.build()
}
}
return null
}
}