Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.bucketeer.sdk.android.internal.remote

import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import io.bucketeer.sdk.android.BKTException
import io.bucketeer.sdk.android.internal.logd
import io.bucketeer.sdk.android.internal.model.Event
import io.bucketeer.sdk.android.internal.model.SourceId
Expand Down Expand Up @@ -92,35 +93,47 @@ internal class ApiClientImpl(

var responseStatusCode = 0
val result =
actualClient.newCall(request).runCatching {
logd { "--> Fetch Evaluation\n$body" }

val (millis, data) =
measureTimeMillisWithResult {
val rawResponse = execute()
responseStatusCode = rawResponse.code

if (!rawResponse.isSuccessful) {
throw rawResponse.toBKTException(errorResponseJsonAdapter)
runCatching {
retryOnException(
maxRetries = 3,
delayMillis = 1000L,
exceptionCheck = { err ->
err is BKTException.ClientClosedRequestException
},
) {
// Clone request to avoid issues with reusing the same request instance
val cloneRequest = request.newBuilder().build()
val call =
actualClient.newCall(cloneRequest)
logd { "--> Fetch Evaluation\n$body" }

val (millis, data) =
measureTimeMillisWithResult {
val rawResponse = call.execute()
responseStatusCode = rawResponse.code

if (!rawResponse.isSuccessful) {
throw rawResponse.toBKTException(errorResponseJsonAdapter)
}

val response =
requireNotNull(rawResponse.fromJson<GetEvaluationsResponse>()) { "failed to parse GetEvaluationsResponse" }

response to (rawResponse.body?.contentLength() ?: -1).toInt()
}

val response =
requireNotNull(rawResponse.fromJson<GetEvaluationsResponse>()) { "failed to parse GetEvaluationsResponse" }

response to (rawResponse.body?.contentLength() ?: -1).toInt()
}

val (response, contentLength) = data
val (response, contentLength) = data

logd { "--> END Fetch Evaluation" }
logd { "<-- Fetch Evaluation\n$response\n<-- END Evaluation response" }
logd { "--> END Fetch Evaluation" }
logd { "<-- Fetch Evaluation\n$response\n<-- END Evaluation response" }

GetEvaluationsResult.Success(
value = response,
seconds = millis / 1000.0,
sizeByte = contentLength,
featureTag = featureTag,
)
GetEvaluationsResult.Success(
value = response,
seconds = millis / 1000.0,
sizeByte = contentLength,
featureTag = featureTag,
)
}
}

return result.fold(
Expand Down Expand Up @@ -154,24 +167,36 @@ internal class ApiClientImpl(

var responseStatusCode = 0
val result =
client.newCall(request).runCatching {
logd { "--> Register events\n$body" }
val response = execute()
responseStatusCode = response.code

if (!response.isSuccessful) {
val e = response.toBKTException(errorResponseJsonAdapter)
logd(throwable = e) { "<-- Register events error" }
throw e
}
runCatching {
retryOnException(
maxRetries = 3,
delayMillis = 1000L,
exceptionCheck = { err ->
err is BKTException.ClientClosedRequestException
},
) {
// Clone request to avoid issues with reusing the same request instance
val cloneRequest = request.newBuilder().build()
val call =
client.newCall(cloneRequest)
logd { "--> Register events\n$body" }
val response = call.execute()
responseStatusCode = response.code

if (!response.isSuccessful) {
val e = response.toBKTException(errorResponseJsonAdapter)
logd(throwable = e) { "<-- Register events error" }
throw e
}

val result =
requireNotNull(response.fromJson<RegisterEventsResponse>()) { "failed to parse RegisterEventsResponse" }
val result =
requireNotNull(response.fromJson<RegisterEventsResponse>()) { "failed to parse RegisterEventsResponse" }

logd { "--> END Register events" }
logd { "<-- Register events\n$result\n<-- END Register events" }
logd { "--> END Register events" }
logd { "<-- Register events\n$result\n<-- END Register events" }

RegisterEventsResult.Success(value = result)
RegisterEventsResult.Success(value = result)
}
}

return result.fold(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package io.bucketeer.sdk.android.internal.remote

/**
* Retries the given [block] of code up to [maxRetries] times if it throws an exception
* that satisfies [exceptionCheck]. Linear backoff delay is applied between retries.
*
* Note: This function blocks the current thread during delays. Use only on background threads.
*
* @param maxRetries Maximum retry attempts (default: 3).
* @param delayMillis Base delay in milliseconds between retries (default: 1000ms).
* @param exceptionCheck Predicate to determine if an exception is retriable.
* @param block Code block to execute.
* @return Result of [block] if successful.
* @throws Throwable Last exception if retries fail or exception is not retriable.
*/
internal fun <T> retryOnException(
maxRetries: Int = 3,
delayMillis: Long = 1000,
exceptionCheck: (Throwable) -> Boolean,
block: () -> T,
): T {
var lastException: Throwable? = null
val maxAttempts = if (maxRetries < 0) 1 else maxRetries + 1
for (attempt in 0 until maxAttempts) {
try {
return block()
} catch (e: Throwable) {
lastException = e
if (!exceptionCheck(e) || attempt >= maxAttempts - 1) {
throw e
}
// Delay with linear backoff using Thread.sleep.
// Ensure this runs on a background thread as it blocks the current thread.
// Coroutine support is not used since the SDK does not rely on coroutines.
Thread.sleep(delayMillis * (attempt + 1))
}
}
// This line should never be reached, but Kotlin compiler requires a return statement here.
throw lastException!!
}
Original file line number Diff line number Diff line change
Expand Up @@ -1420,6 +1420,134 @@ class BKTClientImplTest {
.isEqualTo(MetricsEventType.INTERNAL_SERVER_ERROR)
}

@Test
fun `fetchEvaluations - should retry on 499 status code`() {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
moshi
.adapter(GetEvaluationsResponse::class.java)
.toJson(
GetEvaluationsResponse(
evaluations = user1Evaluations,
userEvaluationsId = "user_evaluations_id_value",
),
),
),
)
server.enqueue(MockResponse().setResponseCode(499))
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
moshi
.adapter(GetEvaluationsResponse::class.java)
.toJson(
GetEvaluationsResponse(
evaluations = user1Evaluations,
userEvaluationsId = "user_evaluations_id_value_updated",
),
),
),
)

val initializeFuture =
BKTClient.initialize(
ApplicationProvider.getApplicationContext(),
config,
user1.toBKTUser(),
1000,
)
initializeFuture.get()

val client = BKTClient.getInstance() as BKTClientImpl
val result = client.fetchEvaluations().get()

Thread.sleep(100)

assertThat(result).isNull()

assertThat(
client.componentImpl.dataModule.evaluationStorage
.getCurrentEvaluationId(),
).isEqualTo("user_evaluations_id_value_updated")

assertThat(
client.componentImpl.dataModule.evaluationStorage
.get(),
).hasSize(2)

// 2 metrics events (latency , size) from the BKTClient internal init()
// 2 metrics events (latency , size) from the test code above
// Because we filter duplicate
// Finally we will have only 2 items, no error event because of the retry
val actualEvents =
client.componentImpl.dataModule.eventSQLDao
.getEvents()
assertThat(actualEvents).hasSize(2)

// server.requestCount includes the initial request plus retries
assertThat(server.requestCount).isEqualTo(3)
}

@Test
fun `fetchEvaluations - fail after retry 3 times - should log only 1 error event`() {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
moshi
.adapter(GetEvaluationsResponse::class.java)
.toJson(
GetEvaluationsResponse(
evaluations = user1Evaluations,
userEvaluationsId = "user_evaluations_id_value",
),
),
),
)
server.enqueue(MockResponse().setResponseCode(499))
server.enqueue(MockResponse().setResponseCode(499))
server.enqueue(MockResponse().setResponseCode(500))

val initializeFuture =
BKTClient.initialize(
ApplicationProvider.getApplicationContext(),
config,
user1.toBKTUser(),
1000,
)
initializeFuture.get()

val client = BKTClient.getInstance() as BKTClientImpl
val result = client.fetchEvaluations().get()

Thread.sleep(100)

assertThat(result).isInstanceOf(BKTException.InternalServerErrorException::class.java)

assertThat(
client.componentImpl.dataModule.evaluationStorage
.getCurrentEvaluationId(),
).isEqualTo("user_evaluations_id_value")

assertThat(
client.componentImpl.dataModule.evaluationStorage
.get(),
).hasSize(2)

// 2 metrics events (latency , size) from the BKTClient internal init()
// 1 metrics events (error) from the test code above
val actualEvents =
client.componentImpl.dataModule.eventSQLDao
.getEvents()
assertThat(actualEvents).hasSize(3)

// server.requestCount includes the initial request plus retries
assertThat(server.requestCount).isEqualTo(4)
}

@Test
fun `fetchEvaluations - onUpdateListener failure`() {
server.enqueue(
Expand Down
Loading
Loading