Skip to content

Commit

Permalink
Add a retry mechanism if we get unknown host/connect exceptions
Browse files Browse the repository at this point in the history
  • Loading branch information
jsoberg committed Apr 28, 2024
1 parent 25d6633 commit a0335bc
Show file tree
Hide file tree
Showing 3 changed files with 98 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package com.soberg.netinfo.android.app.di

import com.soberg.netinfo.data.ipconfig.IpConfigWanInfoRepository
import com.soberg.netinfo.data.ipconfig.IpConfigWanInfoRepository.HttpClientProvider
import com.soberg.netinfo.data.ipconfig.IpConfigWanInfoRepository.KtorHttpClientProvider
import com.soberg.netinfo.domain.wan.WanInfoRepository
import dagger.Binds
import dagger.Module
Expand All @@ -17,7 +15,8 @@ internal abstract class IpConfigModule {
companion object {
@Provides
@Singleton
internal fun provideHttpClientProvider(): HttpClientProvider = KtorHttpClientProvider()
internal fun provideHttpQuery(): IpConfigWanInfoRepository.HttpQuery =
IpConfigWanInfoRepository.KtorHttpQuery()
}

@Binds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,31 @@ import com.soberg.netinfo.data.ipconfig.model.WanInfoNetworkModel
import com.soberg.netinfo.data.ipconfig.model.toDomain
import com.soberg.netinfo.domain.wan.WanInfoRepository
import com.soberg.netinfo.domain.wan.model.WanInfo
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.net.ConnectException
import java.net.UnknownHostException
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds

class IpConfigWanInfoRepository @Inject constructor(
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
private val clientProvider: HttpClientProvider,
private val query: HttpQuery,
) : WanInfoRepository {

companion object {
private const val Tag = "IpConfigWanInfoRepository"
private val UnknownHostTimeout = 1.seconds
}

override suspend fun loadWanInfo(): WanInfoRepository.Result = withContext(ioDispatcher) {
runCatching {
// Note: This client would normally be reused, but here we always want the client to use the latest active network interface.
clientProvider().use { client ->
runQuery(client)
}
runQuery(query, isUnknownHostRetry = false)
}.fold(
onSuccess = { info ->
Logger.debug(Tag, "Query to ipconfig.io succeeded, IP ${info.ip}")
Expand All @@ -41,20 +43,54 @@ class IpConfigWanInfoRepository @Inject constructor(
)
}

private suspend fun runQuery(client: HttpClient): WanInfo {
val response = client.get(IpConfigUrl.Main)
private suspend fun runQuery(
query: HttpQuery,
isUnknownHostRetry: Boolean,
): WanInfo {
val response = runCatching {
queryForResponse(query, isUnknownHostRetry)
}.fold(
onSuccess = { it },
onFailure = { error ->
if (!isUnknownHostRetry && (error is UnknownHostException || error is ConnectException)) {
// We've received a connection exception even though we appear to be active, retry.
Logger.debug(Tag, "Retrying ipconfig.io query...")
queryForResponse(query, isBadConnectionRetry = true)
} else {
error("Unable to retrieve WAN info")
}
},
)

return if (response.status == HttpStatusCode.OK) {
response.body<WanInfoNetworkModel>()
.toDomain()
.getOrThrow()
} else {
error("Received status code ${response.status}")
error("Unable to retrieve WAN info, status code ${response.status}")
}
}

fun interface HttpClientProvider : () -> HttpClient
private suspend fun queryForResponse(
query: HttpQuery,
isBadConnectionRetry: Boolean,
): HttpResponse {
// Slight delay to allow time for connection to be made/make sure we're not throttling ipconfig.io
if (isBadConnectionRetry) {
delay(UnknownHostTimeout)
}

return query(IpConfigUrl.Main)
}

fun interface HttpQuery : suspend (String) -> HttpResponse

class KtorHttpClientProvider : HttpClientProvider {
override fun invoke(): HttpClient = IpConfigKtorClient.create()
class KtorHttpQuery : HttpQuery {
override suspend fun invoke(url: String): HttpResponse {
// Note: This client would normally be reused, but here we always want the client to use the latest active network interface.
IpConfigKtorClient.create().use { client ->
return client.get(url)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,24 @@ import com.soberg.netinfo.base.type.geodetic.Location
import com.soberg.netinfo.base.type.geodetic.Region
import com.soberg.netinfo.base.type.geodetic.ZipCode
import com.soberg.netinfo.base.type.network.IpAddress
import com.soberg.netinfo.data.ipconfig.IpConfigWanInfoRepository.HttpQuery
import com.soberg.netinfo.domain.model.WanInfoTestFixtures
import com.soberg.netinfo.domain.wan.WanInfoRepository
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.request.get
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.utils.io.ByteReadChannel
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.net.ConnectException
import java.net.UnknownHostException

internal class IpConfigWanInfoRepositoryTest {

Expand Down Expand Up @@ -138,6 +145,45 @@ internal class IpConfigWanInfoRepositoryTest {
assertThat(result).isEqualTo(WanInfoRepository.Result.Error)
}

@Test
fun `retry when UnknownHostException received`() =
runTest(ioDispatcher) {
val query: HttpQuery = mockk {
coEvery { this@mockk.invoke(IpConfigUrl.Main) } throws UnknownHostException("Test")
}
val repo = IpConfigWanInfoRepository(ioDispatcher, query)

val result = repo.loadWanInfo()
assertThat(result).isEqualTo(WanInfoRepository.Result.Error)
coVerify(exactly = 2) { query.invoke(IpConfigUrl.Main) }
}

@Test
fun `retry when ConnectException received`() =
runTest(ioDispatcher) {
val query: HttpQuery = mockk {
coEvery { this@mockk.invoke(IpConfigUrl.Main) } throws ConnectException("Test")
}
val repo = IpConfigWanInfoRepository(ioDispatcher, query)

val result = repo.loadWanInfo()
assertThat(result).isEqualTo(WanInfoRepository.Result.Error)
coVerify(exactly = 2) { query.invoke(IpConfigUrl.Main) }
}

@Test
fun `not retry when generic exception received`() =
runTest(ioDispatcher) {
val query: HttpQuery = mockk {
coEvery { this@mockk.invoke(IpConfigUrl.Main) } throws IllegalStateException("Test")
}
val repo = IpConfigWanInfoRepository(ioDispatcher, query)

val result = repo.loadWanInfo()
assertThat(result).isEqualTo(WanInfoRepository.Result.Error)
coVerify(exactly = 1) { query.invoke(IpConfigUrl.Main) }
}

private fun create(
bodyContent: String,
statusCode: HttpStatusCode,
Expand All @@ -150,6 +196,7 @@ internal class IpConfigWanInfoRepositoryTest {
)
}
val client = IpConfigKtorClient.create(mockEngine)
return IpConfigWanInfoRepository(ioDispatcher) { client }
val query = HttpQuery { url -> client.get(url) }
return IpConfigWanInfoRepository(ioDispatcher, query)
}
}

0 comments on commit a0335bc

Please sign in to comment.