1. Overview

As developers, we often have to deal with asynchronous or parallel computations. Scala provides a great way to deal with these cases using Futures, whether it’s a remote call or a very long computation.

If there was a computation error, a network error, or the remote service was down, we may deal with failed Futures in our code. In this article, we’ll learn how to deal with transient errors and create retryable Futures using the retry library.

2. The retry Library

2.1. Adding the Dependency

The first step we need to take is to add the library in our build.sbt file:

libraryDependencies += "com.softwaremill.retry" %% "retry" % "0.3.5"

2.2. Defining the Function That Will Be Retried

Next, we must define our test case that may fail. For our example, we’ll generate a random number from 1 to 100 within a Future as an external service returned it.

def generateRandomNumber: Future[Int] =
  Future {
    Random.nextInt(100)
  }

2.3. Setting the Success Scenario

The next step is to define the success case in our application. We want to accept only prime numbers, so let’s perform a simple check for this:

def isPrime(number: Int) = {
  var prime = true
  for (i <- 2 until number if number % i == 0) {
    prime = false
  }
  prime
}

Next, let’s define an implicit variable that will use this function to determine if our case was a success:

implicit val success: Success[Int] = retry.Success[Int](isPrime)

The above semantics means that the retry library will expect a variable of type Int and will pass it to the isPrime function to create a predicate.

But within the range of our random numbers, 0 is included as well. What happens if we want to include it in our success case? A first approach would be to change our isPrime function to check for that.

The retry library provides, however, a very powerful way to use boolean expressions in our success case. So, let’s modify our success case to be computed by two success cases:

implicit val success: Success[Int] = {
  val successA = retry.Success[Int](_ == 0)
  val successB = retry.Success[Int](isPrime)
  successA.or(successB)
}

This way, we’ve defined that our success case is if the number is 0 or if it’s a prime number.

The library also provides functionality for and-expressions.

2.4. Choosing a retry Method

After having defined all of our functions and variables, we only need to choose a retry method for our function. We can choose from various algorithms defined within the library. The most commonly used is the Backoff algorithm. This will cause the function to retry, either forever or for a defined number of times, using an exponential backoff policy.

So, let’s wrap our retryable function with the exponential backoff policy:

def generateRandomNumber: Future[Int] =
  retry
    .Backoff(5, 5.millis)
    .apply(Future {
      Random.nextInt(100)
    })

Now, we’ve defined that our function will execute again, if it fails, up to five times, with an exponential backoff policy, starting with a 5-millisecond delay.

We can choose between Directly, Pause, Jitter, and JitterBackoff policies, or we can define our own if none of these suits our needs.

3. Defining Different Policies

Our previous example shows a way to retry every time our predicate fails. There are times, however, that we must not retry, such as a malformed request towards an external service. No matter how many times we retry, the result will be the same. For this reason, the retry library allows us to define which exceptions to handle and what kind of policy to use for each case.

3.1. Defining When to Retry

To better understand the above, let’s enhance our generateRandomNumber function to throw some exceptions. We’ll have to deal with two cases — if the number is negative or if it’s greater than 100:

def generateRandomNumber: Future[Int] =
  policy(Future {
    val number = Random.nextInt()
    if (number < 0) {
      throw new UnsupportedOperationException(
        s"Got a negative number: $number"
      )
    } else if (number > 100) {
      throw new IllegalArgumentException(s"Expected number within the [0-100] range. Got $number")
    }
    number
  })

Now, let’s define a new policy that will handle each case:

val policy: Policy = retry
  .When {
    case _: UnsupportedOperationException =>
      retry.Backoff.apply(5, 5.milliseconds)
    case _: IllegalArgumentException =>
      retry.Directly.apply(5)
    case _ =>
      retry.JitterBackoff.apply(5, 5.milliseconds)
  }

Let’s take a closer look at our policy’s behavior:

  • When a negative number is returned, we use the Backoff policy
  • When a number greater than 100 is returned, we use the Directly policy
  • For all other cases, we use the JitterBackoff policy

And finally, all we have to do is wrap our initial function with our new policy:

def generateRandomNumber: Future[Int] =
  policy.apply(Future {
    val number = Random.nextInt()
    if (number < 0) {
      throw new UnsupportedOperationException(
        s"Got a negative number: $number"
      )
    } else if (number > 100) {
      throw new IllegalArgumentException(
        s"Expected number within the [0-100] range. Got $number"
      )
    }
    number
  })

3.2. Defining When to Fail

Having defined how to handle all the cases, let’s assume there’s an exception we know we don’t want to retry. This could be a malformed request, as previously mentioned. So for this, let’s define a new policy that will include our previous policy:

val outerPolicy: Policy = retry.FailFast(policy) {
  case _: NumberFormatException => true
}

FailFast policy has an inner policy that will be triggered if the provided PartialFunction isn’t defined for this Throwable. If, however, it’s defined and the predicate returns true, our Future will fail and won’t be retried. For our case, this will happen if an UnsupportedOperationException is thrown.

For our final step, let’s again wrap our function with the new policy:

def generateRandomNumber: Future[Int] =
  outerPolicy.apply(Future {
    val number = Random.nextInt()
    if (number < 0) {
      throw new UnsupportedOperationException(
        s"Got a negative number: $number"
      )
    } else if (number > 100) {
      throw new IllegalArgumentException(
        s"Expected number within the [0-100] range. Got $number"
      )
    }
    number
  })

4. Issues With the Library

As described in the library’s open issues, there are cases where it will make one extra attempt before failing than desired. This is a scenario we stumbled upon in our tests.

5. Conclusion

In this article, we’ve demonstrated how to use the retry library to handle failures in a Future, use different policies for different exceptions, and avoid unnecessary retries in a non-recovering exception. As always, the code is available over on GitHub.

Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.