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.
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.
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)
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
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>)
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.