Make it easy to repeat something until it succeeds, but at most n times

These loops:

for (i in 0..2) {
    doSomething()
    if(somethingFailed()) {
        continue
    }
    break
}

could maybe be expressed better.

Something like this would be nice:

try (maxTries = 3) {
    doSomething()
    if(somethingFailed()) {
        retry
    }
} 

And:

try (maxTries = 3) {
    doSomething()
} catch(_: BadException) {
    retry
} catch (_: maxTriesExceeded) {
    log("big problem")
}
for(i in 0..2){
	try {
		doSometihng()
		break
	}catch (e: Exception){
		...
	}
}

This can easily be done inside of the language already and I don’t think it is used often so I don’t think it is worth extending the standard library for it. Why not just create a simple utility function in your project?

4 Likes

Alternatively you can do it as a sequence with takeN and firstOrNull. In some cases you can just use the range as a sequence/list instead of using takeN

Well, if the program is very short, of course it makes no real difference. But I think that it would enhance readability in long programs.

Currently, everyone solves it in another way, I think that a unified solution would be better.

And also, all of the solutions have disadvantages: Like, if you choose Wasabi375’s solution. Some weeks later you want to add logging and log a warning in case it failed all 3 times.
Of course this is possible but it is more complex than it could be.

A simple while statement will do. No jumps using break or continue, and a simple condition that clearly states when the body will be executed:

var hasSucceeded = false
var numberOfTriesRemaining = 3
while (!hasSucceeded && numberOfTriesRemaining > 0) {
   numberOfTriesRemaining--
   doSomething()
   hasSucceeded = !somethingFailed()
}

This also allows you to check the final result after the loop.

2 Likes

I don’t think this needs to be added to the language. Retry logic could be done with functions:

fun main(args: Array<String>) {
//sampleStart
    retry(5) {
        println("Will succeed first attempt\n")
    }
    
    retry(5) {
        throw Exception("Will fail after 5 retry attempts")
    }
//sampleEnd
}

fun <T> retry(numOfRetries: Int, block: () -> T): T {
    var throwable: Throwable? = null
    (1..numOfRetries).forEach { attempt ->
        try {
            return block()
        } catch (e: Throwable) {
            throwable = e
            println("Failed attempt $attempt / $numOfRetries")
        }
    }
    throw throwable!!
}
4 Likes

Assuming you don’t have side effects, yes.
Monads ftw btw.

I’m sorry to resurrect this old topic, however, the suggestions provided are awkward so I’d like to explore them. They work sometimes, but there is a very cool Kotlin language design features that do not interact with them well. In particular, its the val/var options and how “try” is an expression not a statement. So in Kotlin you can write this:
val a = try { … } catch { … }

It feels to me very common to want to return a value out of a try block!

But you cannot do so using the first suggestion, because “for” is not an expression:

val a = for(i in 0..2) {  // doesn't work
	try {
		doSometihng()
		break
	}catch (e: Exception){
		...
	}
}

You can use “var” and then set it inside the try and catch, but if the variable is actually a “val” except for this initialization clause, you can’t take advantage of “val”'s benefits. Additionally, you either need to let the var be nullable or init it to a dummy.

What about while loops? Same problem. while loops are statements.

Ok how about wrapping it into a function? Something like this:

fun<T> retry(count: Int, fn:(Int)->T?):T
{
    for(i in 0..count)
    {
        val tmp = fn(i)
        if (tmp != null) return tmp
    }
    throw RetryExceeded()
}

Ok, first problem: awkwardness; we either need to create a new exception to be thrown (as shown) or return null which puts that pesky null back into the code.

Second, there’s this hidden (implied) idea “if you return null, repeat, otherwise return T” in the user code. Here’s an example use:

val data = retry(5) {
  try
  {
       server.request(req, timeout=1000)
  }
  catch (e: RequestTimeout)
  {
      server.close()
      server = connectToAnotherServer()
      null  // ??? weird
  }
}

Note that this also hides the real exception (RequestTimeout). Perhaps what we really want to do it replace the null with:
“if (it < 5) null else throw e”, so that in the last iteration it throws the real error rather than RetryExceeded()? Awkward.

Ok, let’s propose a statement like “break” and “continue” called “again”, and as the OP suggested, allow an optional count as part of the try opener. The semantics of “try(n)”, “again” are that “again” will execute the try block up to n times (as if there was a counter variable that is incremented at the beginning of the try block), and when that’s over, throw e.

val data = try(5) {
       server.request(req, timeout=1000)
  }
  catch (e: RequestTimeout)
  {
      server.close()
      server = connectToAnotherServer()
      again
  }

Anyway, this proposal is cleaner but certainly not perfect. My intention in posting this is more to observe how useful try-as-an-expression is, and yet its use is defeated by the very common try catch design pattern where you want to make a change and retry.

1 Like

Interesting idea.
Try-catch having language level looping mechanics would be interesting. I assume you’d have to watch out for the new ways to get a runtime infinite loop or just require the max iterations int is always passed to the try.

Maybe instead, we can just design a better functional version without the need for a language level change.
Seems like the function implementation has at least these complaints:

  1. Returning null to signal the retry. - I agree this isn’t always good, especially since I want to be able to return a valid null from a retry.
  2. The function hides the real exception or would throw a RetriesExceeded exception alone. - Kind of agree this would be annoying. I would want to have just the original exception or a RetriesExceeded and the attached cause would be the real exception.

I think these can all be solved with a different implementation of the functional one.
For example, you could get this:

val data = retry(5) {
    try {
        server.request(req, timeout=1000)
    } catch (e: RequestTimeout) {
        server.close()
        server = connectToAnotherServer()
        again()
    }
}

Here’s a few other very rough cut (just to show a 5-minute take) of other options-- the main point is that it’s flexible and you can make nearly the exact structure requested by this proposal with functions. The cons listed can be fixed by adjusting the implementation.

rough examples
import kotlin.random.Random

class RetriesExceeded(cause: Throwable): Exception(cause)

// Retry, catching none, call `again()` to try again. 
// -  Allows flagging for retry without a control jump.
// -  If you didin't like the non-control jump behavior, easy to change via private exception.
class RetryContext<out R: Any>(private val block: RetryContext<R>.() -> R) {
    var retry: Boolean = false // Could change visibility, or change the again(), to decide how you'd like users to flag for retry.
    fun eval(): R {
        retry = false
        return this.block()
    }
    fun again() {
        retry = true
    }
}
fun <R: Any> retry2(count: Int, block: RetryContext<R>.() -> R): R {
    repeat(count) { index ->
        val r = RetryContext(block)
        var value = r.eval()
        
        while (r.retry && index + 1 != count) {
            r.eval()
        }
        
        if (index < count) return value
    }
    throw RetriesExceeded(RuntimeException())
}

// Retry, catching provided exceptions, throw to try again.
// - Maybe we don't want to catch all exceptions.
inline fun <R, reified E: Throwable> retry(count: Int, block: () -> R): R {
    repeat(count) { index ->
        try {
            val value = block()
            return value
        } catch(e: Throwable) {
            if (e !is E) throw e
            else if (index + 1 == count) throw RetriesExceeded(e)
        }
    }
    error("Illegal retry state")
}

// Retry, catching all, throw to try again. 
// - tracks the original exception as the cause, alternative, it may not be desireable to wrap the exception for other use-cases.
// - easy to add choice of throwing original exception via an arg flag (ie. throwCause = true)
fun <R> retryExceptions(count: Int, block: () -> R): R {
    repeat(count) { index ->
        try {
            val value = block()
            return value
        } catch(e: Throwable) {
            if (index + 1 == count) throw RetriesExceeded(e)
        }
    }
    error("Illegal retry state")
}

fun main() {
    retryExceptions(3) {
        println("Hello World")
    }
    
    retryExceptions(3) {
        println("Trying 1")
        // 1 / 0 // Fail after reties, showing cause.
    }
    
    retry2(3) {
        println("Trying 2")
        if (Random.nextBoolean()) again()
    }
    
    retry<Unit, ArithmeticException>(3) {
        println("Trying 3")
        throw RuntimeException("Does not get retried")
        // 1 / 0 // Get's retried
    }
}

Don’t get me wrong, I do agree that retry functionality being added to a languages’ try block has benefits. It looks fine too. But “any benefit” isn’t enough to overcome the larger debt of adding it to the core language. See the minus 100 points rule for some of this thinking.

Maybe the alternatives are greatly unsatisfactory?
or that adding an alternative to the stdlib is unsatisfactory?
or maybe the language debt is trivial?
or maybe the benefit applies to a broad and common use-case (or alternatively a narrow but highly impactful use-case)?

A good next step would be to show that the above alternative, with the again() function, is unsatisfactory for use-cases where an again keyword is satisfactory for the same cases.

EDIT:
For the comparison, if you implemented the again() function to be a jump out of the loop, you can leverage Kotlin’s Nothing type. The compiler will warn of unreachable lines and understand that control flow. Since it’s flexible, you could always have both a way to immediately break to trying again and provide an option to flag for retry at the end of the loop.

1 Like

I like it! From a language design perspective, a function classically does not affect its external context, which is why break and continue are not break() and continue(), and modifying global variables deep in some unrelated function is generally considered not good code. So if I was designing Kotlin, I’d still consider adding an “again” keyword to the language (I find myself running into this situation a lot), to keep the language syntax clean.

However, from a practical perspective, I think your implementation will work cleanly for any one who needs it.

This is a good case for using tail recursion

tailrec fun <T> retry(numberOfTries: Int, block: () -> T): T {
    try {
        return block()
    } catch (e: Throwable) {
        if (numberOfTries > 1)
            return retry(numberOfTries - 1, block)
        else
            throw e
    }
}
2 Likes

If appropriate, you could give a default value for the number of tries, making calls even simpler.

One minor drawback of the tail-recursive version is that it doesn’t track how many attempts have been made so far (only the number remaining), so it can’t log that. You could do that with an additional parameter (defaulting to 0).

I just this week refactored some code that was not retrying on errors but was polling for a job on the server to complete and increasing delay between polls and here is basically what I did:

    private suspend fun pollJob(jobId: String, maxAttempts: Int) =
        (0..<maxAttempts)
            .asFlow()
            .onEach { delay(it.seconds) }
            .map { getJobResult(jobId) }
            .firstOrNull { it.status != PROCESSING }

It returns null if the job never completed.

Thinking how one could make this more reusable i could have done this (with and without suspend)::

    private fun <T> retry(maxAttempts: Int, getResult: (Int) -> T?) =
        (1..maxAttempts)
            .asSequence()
            .map(getResult)
            .filterNotNull()
            .firstOrNull()

    private suspend fun <T> retry(maxAttempts: Int, getResult: suspend (Int) -> T?) =
        (1..maxAttempts)
            .asFlow()
            .map(getResult)
            .filterNotNull()
            .firstOrNull()

Which i could have used like this:

    retry(maxAttempts = 5) { attempt ->
        delay((attempt - 1).seconds)
        getJobResult(jobId).takeUnless { it.status == PROCESSING }
    }

Something like this could be used for the examples in the original post:

    retry(3) { doSomething().takeUnless { somethingFailed() } }

    retry(3) {
        runCatching { doSomething() }
              .takeUnless { it.exceptionOrNull() is BadException }
              ?.getOrThrow()
    }
2 Likes