How to retry a Future in Scala

Julien Truffaut

9th November 2023

In Scala, the Future type is a versatile tool for managing asynchronous programming. It proves especially handy for tasks like concurrent or parallel programming, such as making HTTP calls to external APIs or fetching data from databases.

However, what happens when a Future fails, perhaps due to a temporarily inaccessible service? In such cases, the ability to retry the Future becomes crucial. Let's explore how to achieve this.

Leveraging fallbackTo

The Future API provides a helpful method called fallbackTo, allowing you to execute a second Future in case the first one encounters an error. For example:

trait UserAPI { def getUser(userId: String): Future[User] } val api: UserAPI = ... api.getUser("1234") .fallbackTo(api.getUser("1234")) // res: Future[User] = Future(<not completed>)

This snippet attempts to fetch a user by their ID. If the first attempt fails, it gracefully retries once. If the second attempt also fails, the code stops and returns the initial error.

Generalizing with retry

To generalize this pattern, let's create a retry method that takes an arbitrary Future and retries it up to n times using recursion:

import scala.annotation.tailrec @tailrec def retry[A](future: Future[A], remainingAttempts: Int): Future[A] = if (remainingAttempts <= 0) future else retry(future.fallbackTo(future), remainingAttempts - 1)

Addressing Laziness

The initial retry method has a flaw; it doesn't work as intended because Future represents either a running or completed computation. To address this, we need to capture the Future parameter lazily using a by-name parameter (the extra => in the first input definition):

@tailrec def retry[A](future: => Future[A], remainingAttempts: Int): Future[A] = if (remainingAttempts <= 0) future else retry(future.fallbackTo(future), remainingAttempts - 1)

However, it's essential to note that fallbackTo itself is not lazy! Thank you Alexandros Bourantas for letting me know. This means that it executes the two futures even if the first one succeeds. Here is an example:

Future { println("Hey") } .fallBackTo( Future { println("Bye") } ) // Hey // Bye

Leveraging recoverWith

We can use the method recoverWith to implement a lazy version of fallbackTo:

def retry[A](future: => Future[A], remainingAttempts: Int): Future[A] = if (remainingAttempts <= 0) future else future.recoverWith { case e => retry(future, remainingAttempts - 1) }

And now, retry works as intended:

retry(api.getUser("1234"), 5) // res: Future[User] = Future(<not completed>)

Fine-tuning with Error Handling

For more refined control, we may want to retry a Future only when specific errors occur. To achieve this, we can add a guard inside recoverWith:

def partialRetry[A](future: => Future[A], remainingAttempts: Int, retryFor: Throwable => Boolean): Future[A] = if (remainingAttempts <= 0) future else partialRetry( future.recoverWith{ case e if retryFor(e) => future }, remainingAttempts - 1, retryFor )

This allows you to customize error handling and retry conditions, ensuring a more tailored approach to handling failures.

Now, armed with these techniques, you can confidently handle errors and retries in your asynchronous Scala code.

Subscribe to receive the latest Scala jobs in your inbox

Receive a weekly overview of Scala jobs by subscribing to our mailing list

© 2024 ScalaJobs.com, All rights reserved.