How to retry network requests automatically in Android + Kotlin

3 popular ways in 2023: RxJava / Coroutines / OkHttp

Andrei Riik
MobilePeople

--

Photo by Mike van den Bos on Unsplash

One of the pitfalls we should keep in mind when adding every new feature to our mobile apps is the error handling. As part of this, we have to remember that mobile internet quality can be very different even within big cities. There are buildings with thick walls and there are tunnels. Add to this possible back-end internal errors and things become not so straightforward as we could want.

One of the possible solutions to such issues is automatic retrying for failed requests. Mainly it includes 3–5 retries with increasing delay for each next retry. We need this increasing delay to mitigate the risk of spamming our back end if something goes wrong with it.

Of course automatic retries don’t prevent us from showing some kind of “retry button” for user if all retries fail, as well as implementing other possible strategies like reacting on internet availability. But let’s focus on the first option in this article.

Requirements for solution

What kind of solution may we want as developers?

  • Easy to use. One-line wrapper is ideal. Or even global configuration on the network layer.
  • Customizable. We may want to implement different retry strategies for different errors.
  • Fits our tech stack. Of course, we don’t want to migrate from RxJava to Coroutines or vice versa just because of a particular solution.

In this article I’ll share how to implement a retrying with:

  • RxJava
  • Kotlin Coroutines
  • OkHttp interceptors

RxJava

Everything is a stream. Even errors.

There are many ways to implement the desired behaviour in Rx. In this solution we will use .retryWhen() function because it gives more flexibility than just plain .repeat() and easier then custom Observable.

It’s a bit tricky function, but the main idea is: we should “map” it to Observable.error only if we want to pass an error to the downstream, otherwise it will re-subscribe to the upstream.

This interface of retrying function fits most needs:

fun <T : Any> Observable<T>.withRetrying(
fallbackValue: T?,
tryCnt: Int,
intervalMillis: (attempt: Int) -> Long,
retryCheck: (Throwable) -> Boolean,
): Observable<T>

We can specify:

  • fallbackValue value to emit if all retries ended with failure. Or null if we are ready to handle an error somewhere in downstream.
  • tryCnt is how many times in total we will try to request and re-request.
  • intervalMillis — is a lambda in which we can implement the increasing delay.
  • retryCheck — is a lambda in which we can decide whether we need to retry this particular error or not. Usually, network errors and 5xx HTTP codes are subjects to retry, but 4xx codes are not.

And the implementation:

fun <T : Any> Observable<T>.withRetrying(
fallbackValue: T?,
tryCnt: Int,
intervalMillis: (attempt: Int) -> Long,
retryCheck: (Throwable) -> Boolean,
): Observable<T> {
if (tryCnt <= 0) {
return this
}
return this
.retryWhen { errors ->
errors
.zipWith(
Observable.range(1, tryCnt)
) { th: Throwable, attempt: Int ->
if (retryCheck(th) && attempt < tryCnt) {
Observable.timer(intervalMillis(attempt), TimeUnit.MILLISECONDS)
} else {
Observable.error(th)
}
}
.flatMap { it }
}
.let {
if (fallbackValue == null) {
it
} else {
it.onErrorResumeNext { Observable.just(fallbackValue) }
}
}
}

Basically, it’s an evolution of what is described as an example in the documentation for the.retryWhen() function.

Also, it’s a good idea to write additional wrappers for Single and other stream types:

fun <T : Any> Single<T>.withRetrying(
fallbackValue: T?,
tryCnt: Int,
intervalMillis: (attempt: Int) -> Long,
retryCheck: (Throwable) -> Boolean,
): Single<T> = this
.toObservable()
.withRetrying(fallbackValue, tryCnt, intervalMillis, retryCheck)
.firstOrError()

For further simplification, it can be wrapped to project-common functions. For instance, if your common policy is to retry 3 times with increasing delay, it will be like this:

fun <T : Any> Single<T>.commonRetrying(fallbackValue: T? = null) =
withRetrying(fallbackValue, 3, { 2000L * it }, networkRetryCheck)

private val networkRetryCheck: (Throwable) -> Boolean = {
val shouldRetry = when {
it.isHttp4xx() -> false
else -> true
}
shouldRetry
}

Final usage example

Somewhere in your repository layer, it’s exactly plus one extra line:

fun getSomething(params: String): Single<YourResponseType> =
api.getSomething(params)
.commonRetrying()

Kotlin Coroutines

We can use basically the same interface parameters in coroutines as we discussed in case of Rx. It allows us to configure desired behaviour in the same way as above:

suspend fun <T> retrying(
fallbackValue: T?,
tryCnt: Int,
intervalMillis: (attempt: Int) -> Long,
retryCheck: (Throwable) -> Boolean,
block: suspend () -> T,
): T {
try {
val retryCnt = tryCnt - 1
repeat(retryCnt) { attempt ->
try {
return block()
} catch (e: Exception) {
if (e is CancellationException || !retryCheck(e)) {
throw e
}
}
delay(intervalMillis(attempt + 1))
}
return block()
} catch (e: Exception) {
if (e is CancellationException) {
throw e
}
return fallbackValue ?: throw e
}
}

The algorithm is simple:

  • Try retryCnt times in a loop and one more time after the loop.
  • Check for given retryCheck if we need to retry after the particular exception or not. Delay before next try for intervalMillis if yes.
  • If all tries failed but we have a fallbackValue to return —return it. Otherwise, throw the error further.

Probably the main pitfall in suspend functions like this that makes our code longer than it could be — we need to remember about CancellationException and its role in Coroutines.

Also we can unify the solution above to a particular project needs, similar to how we made it with Rx:

suspend fun <T> commonRetrying(
fallbackValue: T?,
block: suspend () -> T,
): T = retrying(fallbackValue, 3, { 2000L * it }, networkRetryCheck, block)

Final usage example

Somewhere in your repository layer, it’s nearly one extra line:

suspend fun getSomething() = commonRetrying {
api.getSomething()
}

By the way, it simply fits Universal Cache.
The following code wraps all our underlying retries to a cached source as if it’s just a single request:

val listOfSomething = CachedSourceNoParams(
source = {
commonRetrying {
api.getSomething()
}
}
)

OkHttp interceptors

Previous solutions are flexible and support a variety of parameters. Also, they can be used not only for network but for any kind of operations or calculations.

On the other hand, someone can forget to wrap an API-call into such functions. In this case, we can chose to implement retrying logic into the network layer and into OkHttp in particular. But it has some limitations in comparison to previous examples. It’s more difficult to apply a specific retry policy for only particular requests. Also, it will not retry if an error occurs somewhere between network call and the invocation side, for instance on response data parsing phase. Good it or bad — it depends on your project's needs.

Basic implementations look like the following. It doesn’t contain a check for 4xx and 5xx HTTP codes, but it also can be implemented.

import okhttp3.Interceptor
import okhttp3.Response

class RetryingInterceptor : Interceptor {

private val tryCnt = 3
private val baseInterval = 2000L

override fun intercept(chain: Interceptor.Chain): Response {
return process(chain, attempt = 1)
}

private fun process(chain: Interceptor.Chain, attempt: Int): Response {
var response: Response? = null
try {
val request = chain.request()
response = chain.proceed(request)
if (attempt < tryCnt && !response.isSuccessful) {
return delayedAttempt(chain, response, attempt)
}
return response
} catch (e: Exception) {
if (attempt < tryCnt && networkRetryCheck(e)) {
return delayedAttempt(chain, response, attempt)
}
throw e
}
}

private fun delayedAttempt(
chain: Interceptor.Chain,
response: Response?,
attempt: Int,
): Response {
response?.body?.close()
Thread.sleep(baseInterval * attempt)
return process(chain, attempt = attempt + 1)
}
}

Here we do retry with a delay if:
a) Check for .isSuccessful fails
b) An exception occurs

The important thing here is to not forget to close a previous response body as it’s suggested in the documentation:

If chain.proceed(request) is being called more than once previous response bodies must be closed.

That interceptor can be injected in the following way when you construct your OkHttpClient (which can be passed into a Retrofit configuration):

val client = OkHttpClient.Builder()
.addInterceptor(RetryingInterceptor())
.build()

Final usage example

Not this time. There are no extra lines on the caller’s side in comparison with previous solutions. Just set it up once and forget.

Conclusion

As we’ve discussed, it’s a good idea to handle possible network requests instability. It’s the kind of nature of network that we should keep in mind. We just went through 3 solutions, each works a bit different and the decision of what to use depends on your project specifics.

If you need a single retrying policy for the entire application — OkHttp interceptor is a reasonable choice. If you need more control over specific requests or you need to retry something that doesn’t use OkHttp under the hood — the decision depends on your tech stack. Nowadays, it’s usually RxJava or Kotlin Coroutines. Both of them are flexible enough for this task.

Thanks for reading!

--

--