How to retry network requests automatically in Android + Kotlin
3 popular ways in 2023: RxJava / Coroutines / OkHttp
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 forintervalMillis
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!