shakil.k
shakil.k

Reputation: 1723

How to Exponential Backoff retry on kotlin coroutines

I am using kotlin coroutines for network request using extension method to call class in retrofit like this

public suspend fun <T : Any> Call<T>.await(): T {

  return suspendCancellableCoroutine { continuation -> 

    enqueue(object : Callback<T> {

        override fun onResponse(call: Call<T>?, response: Response<T?>) {
            if (response.isSuccessful) {
                val body = response.body()
                if (body == null) {
                    continuation.resumeWithException(
                            NullPointerException("Response body is null")
                    )
                } else {
                    continuation.resume(body)
                }
            } else {
                continuation.resumeWithException(HttpException(response))
            }
        }

        override fun onFailure(call: Call<T>, t: Throwable) {
            // Don't bother with resuming the continuation if it is already cancelled.
            if (continuation.isCancelled) return
            continuation.resumeWithException(t)
        }
    })

      registerOnCompletion(continuation)
  }
}

then from calling side i am using above method like this

private fun getArticles()  = launch(UI) {

    loading.value = true
    try {
        val networkResult = api.getArticle().await()
        articles.value =  networkResult

    }catch (e: Throwable){
        e.printStackTrace()
        message.value = e.message

    }finally {
        loading.value = false
    }

}

i want to exponential retry this api call in some case i.e (IOException) how can i achieve it ??

Upvotes: 62

Views: 37677

Answers (6)

Emanuel Moecklin
Emanuel Moecklin

Reputation: 28866

Improved version of @Roman Elizarov's answer using an Iterator/Sequence for the backoff strategy:

private suspend fun <T> retry(
    times: Int = 3,              // retry three times
    backoffStrategy: Iterator<Long>,
    predicate: (R) -> Boolean = { false },
    block: suspend (attempt: Int) -> T): T
{
    repeat(times - 1) { attempt ->
        val result = block(attempt + 1)
        if (predicate(result)) {
            delay(backoffStrategy.next())
        } else {
            return result
        }
    }
    return block(times) // last attempt
}

Using the Iterator separates the retry logic from the backoff strategy which can be as simple as:

// generates 1000, 1000, 1000 etc.
val linearBackoff = generateSequence(1000) { it }.iterator()

or more sophisticated:

val exponentialBackoff = backoffStrategy()
val constantBackoff = backoffStrategy(factor = 1.0)

fun backoffStrategy(
    initialDelay: Long = 1000,   // 1 second
    maxDelay: Long = 20000,      // 10 second
    factor: Double = 2.0,        // exponential backoff base 2
) = generateSequence(initialDelay) { previous ->
    previous.times(factor).toLong().coerceAtMost(maxDelay)
}.iterator()

Note: the code to be executed (block) is responsible for handling exceptions. I typically do railway oriented programming so T is something like Either<Error, T> or Result<T>.

Upvotes: 1

Flow Version https://github.com/hoc081098/FlowExt

package com.hoc081098.flowext

import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.retryWhen

@ExperimentalTime
public fun <T> Flow<T>.retryWithExponentialBackoff(
  initialDelay: Duration,
  factor: Double,
  maxAttempt: Long = Long.MAX_VALUE,
  maxDelay: Duration = Duration.INFINITE,
  predicate: suspend (cause: Throwable) -> Boolean = { true }
): Flow<T> {
  require(maxAttempt > 0) { "Expected positive amount of maxAttempt, but had $maxAttempt" }
  return retryWhenWithExponentialBackoff(
    initialDelay = initialDelay,
    factor = factor,
    maxDelay = maxDelay
  ) { cause, attempt -> attempt < maxAttempt && predicate(cause) }
}

@ExperimentalTime
public fun <T> Flow<T>.retryWhenWithExponentialBackoff(
  initialDelay: Duration,
  factor: Double,
  maxDelay: Duration = Duration.INFINITE,
  predicate: suspend FlowCollector<T>.(cause: Throwable, attempt: Long) -> Boolean
): Flow<T> = flow {
  var currentDelay = initialDelay

  retryWhen { cause, attempt ->
    predicate(cause, attempt).also {
      if (it) {
        delay(currentDelay)
        currentDelay = (currentDelay * factor).coerceAtMost(maxDelay)
      }
    }
  }.let { emitAll(it) }
}

Upvotes: 1

diAz
diAz

Reputation: 516

Here an example with the Flow and the retryWhen function

RetryWhen Extension :

fun <T> Flow<T>.retryWhen(
    @FloatRange(from = 0.0) initialDelay: Float = RETRY_INITIAL_DELAY,
    @FloatRange(from = 1.0) retryFactor: Float = RETRY_FACTOR_DELAY,
    predicate: suspend FlowCollector<T>.(cause: Throwable, attempt: Long, delay: Long) -> Boolean
): Flow<T> = this.retryWhen { cause, attempt ->
    val retryDelay = initialDelay * retryFactor.pow(attempt.toFloat())
    predicate(cause, attempt, retryDelay.toLong())
}

Usage :

flow {
    ...
}.retryWhen { cause, attempt, delay ->
    delay(delay)
    ...
}

Upvotes: 5

Sir Codesalot
Sir Codesalot

Reputation: 7293

You can try this simple but very agile approach with simple usage:

EDIT: added a more sophisticated solution in a separate answer.

class Completion(private val retry: (Completion) -> Unit) {
    fun operationFailed() {
        retry.invoke(this)
    }
}

fun retryOperation(retries: Int, 
                   dispatcher: CoroutineDispatcher = Dispatchers.Default, 
                   operation: Completion.() -> Unit
) {
    var tryNumber = 0

    val completion = Completion {
        tryNumber++
        if (tryNumber < retries) {
            GlobalScope.launch(dispatcher) {
                delay(TimeUnit.SECONDS.toMillis(tryNumber.toLong()))
                operation.invoke(it)
            }
        }
    }

    operation.invoke(completion)
}

The use it like this:

retryOperation(3) {
    if (!tryStuff()) {
        // this will trigger a retry after tryNumber seconds
        operationFailed()
    }
}

You can obviously build more on top of it.

Upvotes: 0

Sir Codesalot
Sir Codesalot

Reputation: 7293

Here's a more sophisticated and convenient version of my previous answer, hope it helps someone:

class RetryOperation internal constructor(
    private val retries: Int,
    private val initialIntervalMilli: Long = 1000,
    private val retryStrategy: RetryStrategy = RetryStrategy.LINEAR,
    private val retry: suspend RetryOperation.() -> Unit
) {
    var tryNumber: Int = 0
        internal set

    suspend fun operationFailed() {
        tryNumber++
        if (tryNumber < retries) {
            delay(calculateDelay(tryNumber, initialIntervalMilli, retryStrategy))
            retry.invoke(this)
        }
    }
}

enum class RetryStrategy {
    CONSTANT, LINEAR, EXPONENTIAL
}

suspend fun retryOperation(
    retries: Int = 100,
    initialDelay: Long = 0,
    initialIntervalMilli: Long = 1000,
    retryStrategy: RetryStrategy = RetryStrategy.LINEAR,
    operation: suspend RetryOperation.() -> Unit
) {
    val retryOperation = RetryOperation(
        retries,
        initialIntervalMilli,
        retryStrategy,
        operation,
    )

    delay(initialDelay)

    operation.invoke(retryOperation)
}

internal fun calculateDelay(tryNumber: Int, initialIntervalMilli: Long, retryStrategy: RetryStrategy): Long {
    return when (retryStrategy) {
        RetryStrategy.CONSTANT -> initialIntervalMilli
        RetryStrategy.LINEAR -> initialIntervalMilli * tryNumber
        RetryStrategy.EXPONENTIAL -> 2.0.pow(tryNumber).toLong()
    }
}

Usage:

coroutineScope.launch {
    retryOperation(3) {
        if (!tryStuff()) {
            Log.d(TAG, "Try number $tryNumber")
            operationFailed()
        }
    }
}

Upvotes: 2

Roman  Elizarov
Roman Elizarov

Reputation: 28678

I would suggest to write a helper higher-order function for your retry logic. You can use the following implementation for a start:

suspend fun <T> retryIO(
    times: Int = Int.MAX_VALUE,
    initialDelay: Long = 100, // 0.1 second
    maxDelay: Long = 1000,    // 1 second
    factor: Double = 2.0,
    block: suspend () -> T): T
{
    var currentDelay = initialDelay
    repeat(times - 1) {
        try {
            return block()
        } catch (e: IOException) {
            // you can log an error here and/or make a more finer-grained
            // analysis of the cause to see if retry is needed
        }
        delay(currentDelay)
        currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
    }
    return block() // last attempt
}

Using this function is very strightforward:

val networkResult = retryIO { api.getArticle().await() }

You can change retry parameters on case-by-case basis, for example:

val networkResult = retryIO(times = 3) { api.doSomething().await() }

You can also completely change the implementation of retryIO to suit the needs of your application. For example, you can hard-code all the retry parameters, get rid of the limit on the number of retries, change defaults, etc.

Upvotes: 223

Related Questions