Skip to content

Commit b58a948

Browse files
committed
fetch improvement
1 parent fe077de commit b58a948

File tree

7 files changed

+173
-70
lines changed

7 files changed

+173
-70
lines changed

src/main/kotlin/com/featurevisor/sdk/DatafileReader.kt

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
11
package com.featurevisor.sdk
22

3-
import com.featurevisor.types.Attribute
4-
import com.featurevisor.types.AttributeKey
5-
import com.featurevisor.types.DatafileContent
6-
import com.featurevisor.types.Feature
7-
import com.featurevisor.types.FeatureKey
8-
import com.featurevisor.types.Segment
9-
import com.featurevisor.types.SegmentKey
10-
11-
class DatafileReader constructor(
3+
import com.featurevisor.types.*
4+
5+
class DatafileReader(
126
datafileJson: DatafileContent,
137
) {
148

src/main/kotlin/com/featurevisor/sdk/FeaturevisorError.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,7 @@ sealed class FeaturevisorError(message: String) : Throwable(message = message) {
1919
class InvalidUrl(val url: String?) : FeaturevisorError("Invalid URL")
2020

2121
object MissingDatafileUrlWhileRefreshing : FeaturevisorError("Missing datafile url need to refresh")
22+
23+
/// Fetching was cancelled
24+
object FetchingDataFileCancelled : FeaturevisorError("Fetching data file cancelled")
2225
}
Lines changed: 125 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,99 @@
11
package com.featurevisor.sdk
22

33
import com.featurevisor.types.DatafileContent
4+
import com.featurevisor.types.DatafileFetchResult
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.Job
7+
import kotlinx.coroutines.delay
8+
import kotlinx.coroutines.launch
49
import kotlinx.serialization.decodeFromString
5-
import java.io.IOException
6-
import okhttp3.*
710
import kotlinx.serialization.json.Json
11+
import okhttp3.*
812
import okhttp3.HttpUrl.Companion.toHttpUrl
9-
import java.lang.IllegalArgumentException
13+
import java.io.IOException
14+
import java.net.ConnectException
15+
import java.net.UnknownHostException
1016

1117
// MARK: - Fetch datafile content
1218
@Throws(IOException::class)
13-
internal fun FeaturevisorInstance.fetchDatafileContent(
19+
internal fun fetchDatafileContentJob(
20+
url: String,
21+
logger: Logger?,
22+
coroutineScope: CoroutineScope,
23+
retryCount: Int = 3, // Retry count
24+
retryInterval: Long = 300L, // Retry interval in milliseconds
25+
handleDatafileFetch: DatafileFetchHandler? = null,
26+
completion: (Result<DatafileFetchResult>) -> Unit,
27+
): Job {
28+
val job = Job()
29+
coroutineScope.launch(job) {
30+
fetchDatafileContent(
31+
url = url,
32+
handleDatafileFetch = handleDatafileFetch,
33+
completion = completion,
34+
retryCount = retryCount,
35+
retryInterval = retryInterval,
36+
job = job,
37+
logger = logger,
38+
)
39+
}
40+
return job
41+
}
42+
43+
internal suspend fun fetchDatafileContent(
1444
url: String,
45+
logger: Logger? = null,
46+
retryCount: Int = 1,
47+
retryInterval: Long = 0L,
48+
job: Job? = null,
1549
handleDatafileFetch: DatafileFetchHandler? = null,
16-
completion: (Result<DatafileContent>) -> Unit,
50+
completion: (Result<DatafileFetchResult>) -> Unit,
1751
) {
1852
handleDatafileFetch?.let { handleFetch ->
19-
val result = handleFetch(url)
20-
completion(result)
53+
for (attempt in 0 until retryCount) {
54+
if (job != null && (job.isCancelled || job.isActive.not())) {
55+
completion(Result.failure(FeaturevisorError.FetchingDataFileCancelled))
56+
break
57+
}
58+
59+
val result = handleFetch(url)
60+
result.fold(
61+
onSuccess = {
62+
completion(Result.success(DatafileFetchResult(it, "")))
63+
return
64+
},
65+
onFailure = { exception ->
66+
if (attempt < retryCount - 1) {
67+
logger?.error(exception.localizedMessage)
68+
delay(retryInterval)
69+
} else {
70+
completion(Result.failure(exception))
71+
}
72+
}
73+
)
74+
}
2175
} ?: run {
22-
fetchDatafileContentFromUrl(url, completion)
76+
fetchDatafileContentFromUrl(
77+
url = url,
78+
completion = completion,
79+
retryCount = retryCount,
80+
retryInterval = retryInterval,
81+
job = job,
82+
logger = logger,
83+
)
2384
}
2485
}
2586

26-
private fun fetchDatafileContentFromUrl(
87+
const val BODY_BYTE_COUNT = 1000000L
88+
private val client = OkHttpClient()
89+
90+
private suspend fun fetchDatafileContentFromUrl(
2791
url: String,
28-
completion: (Result<DatafileContent>) -> Unit,
92+
logger: Logger?,
93+
retryCount: Int,
94+
retryInterval: Long,
95+
job: Job?,
96+
completion: (Result<DatafileFetchResult>) -> Unit,
2997
) {
3098
try {
3199
val httpUrl = url.toHttpUrl()
@@ -34,55 +102,66 @@ private fun fetchDatafileContentFromUrl(
34102
.addHeader("Content-Type", "application/json")
35103
.build()
36104

37-
fetch(request, completion)
105+
fetchWithRetry(
106+
request = request,
107+
completion = completion,
108+
retryCount = retryCount,
109+
retryInterval = retryInterval,
110+
job = job,
111+
logger = logger,
112+
)
38113
} catch (throwable: IllegalArgumentException) {
39114
completion(Result.failure(FeaturevisorError.InvalidUrl(url)))
115+
} catch (e: Exception) {
116+
logger?.error("Exception occurred during datafile fetch: ${e.message}")
117+
completion(Result.failure(e))
40118
}
41119
}
42120

43-
const val BODY_BYTE_COUNT = 1000000L
44-
private inline fun fetch(
121+
private suspend fun fetchWithRetry(
45122
request: Request,
46-
crossinline completion: (Result<DatafileContent>) -> Unit,
123+
logger: Logger?,
124+
completion: (Result<DatafileFetchResult>) -> Unit,
125+
retryCount: Int,
126+
retryInterval: Long,
127+
job: Job?
47128
) {
48-
val client = OkHttpClient()
49-
val call = client.newCall(request)
50-
call.enqueue(object : Callback {
51-
override fun onResponse(call: Call, response: Response) {
129+
for (attempt in 0 until retryCount) {
130+
if (job != null && (job.isCancelled || job.isActive.not())) {
131+
completion(Result.failure(FeaturevisorError.FetchingDataFileCancelled))
132+
return
133+
}
134+
135+
val call = client.newCall(request)
136+
try {
137+
val response = call.execute()
52138
val responseBody = response.peekBody(BODY_BYTE_COUNT)
139+
val responseBodyString = responseBody.string()
53140
if (response.isSuccessful) {
54-
val json = Json {
55-
ignoreUnknownKeys = true
56-
}
57-
val responseBodyString = responseBody.string()
141+
val json = Json { ignoreUnknownKeys = true }
58142
FeaturevisorInstance.companionLogger?.debug(responseBodyString)
59-
try {
60-
val content = json.decodeFromString<DatafileContent>(responseBodyString)
61-
completion(Result.success(content))
62-
} catch(throwable: Throwable) {
63-
completion(
64-
Result.failure(
65-
FeaturevisorError.UnparsableJson(
66-
responseBody.string(),
67-
response.message
68-
)
69-
)
70-
)
143+
val content = json.decodeFromString<DatafileContent>(responseBodyString)
144+
completion(Result.success(DatafileFetchResult(content, responseBodyString)))
145+
return
146+
} else {
147+
if (attempt < retryCount - 1) {
148+
logger?.error("Request failed with message: ${response.message}")
149+
delay(retryInterval)
150+
} else {
151+
completion(Result.failure(FeaturevisorError.UnparsableJson(responseBodyString, response.message)))
71152
}
153+
}
154+
} catch (e: IOException) {
155+
val isInternetException = e is ConnectException || e is UnknownHostException
156+
if (attempt >= retryCount - 1 || isInternetException) {
157+
completion(Result.failure(e))
72158
} else {
73-
completion(
74-
Result.failure(
75-
FeaturevisorError.UnparsableJson(
76-
responseBody.string(),
77-
response.message
78-
)
79-
)
80-
)
159+
logger?.error("IOException occurred during request: ${e.message}")
160+
delay(retryInterval)
81161
}
82-
}
83-
84-
override fun onFailure(call: Call, e: IOException) {
162+
} catch (e: Exception) {
163+
logger?.error("Exception occurred during request: ${e.message}")
85164
completion(Result.failure(e))
86165
}
87-
})
166+
}
88167
}

src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ fun FeaturevisorInstance.startRefreshing() = when {
1717
refreshJob != null -> logger?.warn("refreshing has already started")
1818
refreshInterval == null -> logger?.warn("no `refreshInterval` option provided")
1919
else -> {
20-
refreshJob = CoroutineScope(Dispatchers.Unconfined).launch {
20+
refreshJob = coroutineScope.launch {
2121
while (isActive) {
2222
refresh()
2323
delay(refreshInterval)
@@ -32,20 +32,20 @@ fun FeaturevisorInstance.stopRefreshing() {
3232
logger?.warn("refreshing has stopped")
3333
}
3434

35-
private fun FeaturevisorInstance.refresh() {
35+
private suspend fun FeaturevisorInstance.refresh() {
3636
logger?.debug("refreshing datafile")
3737
when {
3838
statuses.refreshInProgress -> logger?.warn("refresh in progress, skipping")
3939
datafileUrl.isNullOrBlank() -> logger?.error("cannot refresh since `datafileUrl` is not provided")
4040
else -> {
4141
statuses.refreshInProgress = true
4242
fetchDatafileContent(
43-
datafileUrl,
44-
handleDatafileFetch,
43+
url = datafileUrl,
44+
handleDatafileFetch = handleDatafileFetch,
4545
) { result ->
4646

47-
if (result.isSuccess) {
48-
val datafileContent = result.getOrThrow()
47+
result.onSuccess { fetchResult ->
48+
val datafileContent = fetchResult.datafileContent
4949
val currentRevision = getRevision()
5050
val newRevision = datafileContent.revision
5151
val isNotSameRevision = currentRevision != newRevision
@@ -59,7 +59,7 @@ private fun FeaturevisorInstance.refresh() {
5959
}
6060

6161
statuses.refreshInProgress = false
62-
} else {
62+
}.onFailure {
6363
logger?.error(
6464
"failed to refresh datafile",
6565
mapOf("error" to result)

src/main/kotlin/com/featurevisor/sdk/Instance.kt

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package com.featurevisor.sdk
66
import com.featurevisor.sdk.FeaturevisorError.MissingDatafileOptions
77
import com.featurevisor.types.*
88
import com.featurevisor.types.EventName.*
9+
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.Dispatchers
911
import kotlinx.coroutines.Job
1012
import kotlinx.serialization.decodeFromString
1113
import kotlinx.serialization.json.Json
@@ -16,7 +18,7 @@ typealias InterceptContext = (Context) -> Context
1618
typealias DatafileFetchHandler = (datafileUrl: String) -> Result<DatafileContent>
1719

1820
var emptyDatafile = DatafileContent(
19-
schemaVersion = "1",
21+
schemaVersion = "1",
2022
revision = "unknown",
2123
attributes = emptyList(),
2224
segments = emptyList(),
@@ -56,6 +58,8 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) {
5658
internal var configureBucketKey = options.configureBucketKey
5759
internal var configureBucketValue = options.configureBucketValue
5860
internal var refreshJob: Job? = null
61+
private var fetchJob: Job? = null
62+
internal val coroutineScope = CoroutineScope(Dispatchers.Unconfined)
5963

6064
init {
6165
with(options) {
@@ -99,17 +103,26 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) {
99103
}
100104

101105
datafileUrl != null -> {
102-
datafileReader = DatafileReader(options.datafile?: emptyDatafile)
103-
fetchDatafileContent(datafileUrl, handleDatafileFetch) { result ->
104-
if (result.isSuccess) {
105-
datafileReader = DatafileReader(result.getOrThrow())
106+
datafileReader = DatafileReader(options.datafile ?: emptyDatafile)
107+
fetchJob = fetchDatafileContentJob(
108+
url = datafileUrl,
109+
logger = logger,
110+
handleDatafileFetch = handleDatafileFetch,
111+
retryCount = retryCount.coerceAtLeast(1),
112+
retryInterval = retryInterval.coerceAtLeast(0),
113+
coroutineScope = coroutineScope,
114+
) { result ->
115+
result.onSuccess { fetchResult ->
116+
val datafileContent = fetchResult.datafileContent
117+
datafileReader = DatafileReader(datafileContent)
106118
statuses.ready = true
107-
emitter.emit(READY, result.getOrThrow())
119+
emitter.emit(READY, datafileContent, fetchResult.responseBodyString)
108120
if (refreshInterval != null) startRefreshing()
109-
} else {
110-
logger?.error("Failed to fetch datafile: $result")
121+
}.onFailure { error ->
122+
logger?.error("Failed to fetch datafile: $error")
111123
emitter.emit(ERROR)
112124
}
125+
cancelFetchRetry()
113126
}
114127
}
115128

@@ -118,6 +131,12 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) {
118131
}
119132
}
120133

134+
// Provide a mechanism to cancel the fetch job if retry count is more than one
135+
fun cancelFetchRetry() {
136+
fetchJob?.cancel()
137+
fetchJob = null
138+
}
139+
121140
fun setLogLevels(levels: List<Logger.LogLevel>) {
122141
this.logger?.setLevels(levels)
123142
}

src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ data class InstanceOptions(
2323
val onError: Listener? = null,
2424
val refreshInterval: Long? = null, // seconds
2525
val stickyFeatures: StickyFeatures? = null,
26+
val retryInterval: Long = 300L,
27+
val retryCount: Int = 1,
2628
) {
2729
companion object {
2830
private const val defaultBucketKeySeparator = "."

src/main/kotlin/com/featurevisor/types/Types.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,12 @@ data class DatafileContent(
339339
val features: List<Feature>,
340340
)
341341

342+
@Serializable
343+
data class DatafileFetchResult(
344+
val datafileContent: DatafileContent,
345+
val responseBodyString: String
346+
)
347+
342348
@Serializable
343349
data class OverrideFeature(
344350
val enabled: Boolean,

0 commit comments

Comments
 (0)