Retrying HTTP Requests in Scala

In this post we’ll see how to replay a HTTP request. This is useful when interacting with some APIs. For example, we need it at BIME when we use the Google Analytics API. The API is rate-limited, and in some cases the documentation recommends to retry the request later [1]. It would be ideal from an implementation point of view if this process was invisible and if we could write the code as if we were doing only one request.

Introducing dispatch

To perform this task in Scala we use the dispatch library. To install it we just need to add the following line to our build.sbt

libraryDependencies += "net.databinder.dispatch" %% "dispatch-core" % "0.11.2"

Making a request to Google Analytics API

Here is what a simple request to the Google Analytics API looks like using dispatch:

import dispatch._, Defaults._

import scala.concurrent.Await
import scala.concurrent.duration._

object Main extends App {
    val KEY = "YOUR_KEY"
    val TOKEN = "Bearer YOUR_TOKEN"

    val service = url("https://www.googleapis.com/analytics/v3/data/ga?ids=ga%3A39301141&start-index=1&metrics=ga%3Ausers&start-date=2014-04-01&end-date=2015-04-01&max-results=10000&key=" + KEY)
        .GET
        .addHeader("Authorization", TOKEN)
        .addHeader("Content-Type", "application/json")

    val resultFuture = Http(service).either.map {
        case Left(error) => {
            //TODO: retry
        }
        case Right(response) => {
            if (response.getStatusCode == 200) {
                //Handle response
            }
            else {
                //TODO: retry
            }
        }
    }

    resultFuture.map {
        case Left(e) => {
            println("An error happened.")
        }
        case Right(response) => {
            println("Request completed with code: " + response.getStatusCode)
        }
    }

    Await.result(resultFuture, 10.seconds)
}

Note that the Await.result(resultFuture, 10.seconds) should not be used in a real app, this is just to make sure that Main does not terminate before we get a result from our call.

This call can fail due to several reasons (network issues, rate limit exceeded, etc). When we exceed our rate limit the Google Analytics API will return a 403 status code with the reason being either userRateLimitExceeded or quotaExceeded. In these cases the recommended action from the documentation is to retry the call using exponential back-off strategy [2].

Retrying the request

dispatch supports three retry strategies which are Backoff, Directly and Pause. In brief, Backoff will multiply the wait time before each retry by a defined factor, Pause will always wait the same time between each retry, and Directly is pretty obvious! The (simplified) signature of the three functions in dispatch is as follows:

def Backoff[T](max: Int = 8, delay: Duration = Duration(500, TimeUnit.MILLISECONDS), base: Int = 2)(promise: () => Future[T]): Future[T]

def Directly[T](max: Int = 3)(promise: () => Future[T]) : Future[T]

def Pause(max: Int = 4, delay: Duration = Duration(500, TimeUnit.MILLISECONDS))(promise: () => Future[T]): Future[T]

The first set of parameters of these functions allows us to define how many times we want to retry, or the interval between each retry. The second set of parameters is only the request we want to retry. But instead of it being simpy a Future[T] it’s a function that returns Future[T], because once a promise has been completed there is no way to replay it.

For dispatch to consider a future as failing and thus retrying it, it has to return an Either[Throwable, T] or Option[T]. In the first case dispatch will retry the request on every Left[Throwable] and in the second it will retry on every None. To make our example retryable we need to adjust the code as follows:

import dispatch._, Defaults._

import scala.concurrent.Await
import scala.concurrent.duration._

object Main extends App {
    val KEY = "YOUR_KEY"
    val TOKEN = "Bearer YOUR_TOKEN"

    val service = url("https://www.googleapis.com/analytics/v3/data/ga?ids=ga%3A39301141&start-index=1&metrics=ga%3Ausers&start-date=2014-04-01&end-date=2015-04-01&max-results=10000&key=" + KEY)
        .GET
        .addHeader("Authorization", TOKEN)
        .addHeader("Content-Type", "application/json")

    val getResultFuture = () => {
        Http(service).either.map {
            case Left(error) => {
                Left(error)
            }
            case Right(response) => {
                if (response.getStatusCode == 403) {
                    Left(new Exception("api rate limit"))
                }
                else {
                    Right(response)
                }
            }
        }
    }
    val resultFuture = retry.Backoff(max = 4, delay = 5.seconds, base = 2)(getResultFuture)

    resultFuture.map {
        case Left(e) => {
            println("An error happened.")
        }
        case Right(response) => {
            println("Request completed with code: " + response.getStatusCode)
        }
    }

    Await.result(resultFuture, 10.seconds)
}

In this case when the request is failing, or when it’s succeeding with a 403 status code, we return Left[Throwable] so the request will be retried. The request will be retried at most 4 times with an initial delay of 5 seconds and a base of 2. It means that the first time it will wait 5 seconds before retrying, the second time 10 seconds and the third time 20 seconds (and so on if we’d try more that 4 times). This is all there is to it! By simply wrapping an HTTP call in a function and using dispatch retry helpers functions, we are able to make a call to the Google Analytics API and be sure to not fail when we encounter a rate limit error.

Using dispatch.retry with other libraries

The nice thing with dispatch.retry is that we are not forced to use it with dispatch itself. If for example we prefer to use Play WS API [3], we can! Here’s an example of making the same request as the previous one using Play:

package controllers

import dispatch._, Defaults._
import play.api.mvc._
import play.api.Play.current
import play.api.libs.ws._

import scala.concurrent.duration._

object GAController extends Controller {
    def index = Action.async {
        val KEY = "YOUR_KEY"
        val TOKEN = "Bearer YOUR_TOKEN"

        val getResultFuture = () => {
            WS.url("https://www.googleapis.com/analytics/v3/data/ga?ids=ga%3A39301141&start-index=1&metrics=ga%3Ausers&start-date=2014-04-01&end-date=2015-04-01&max-results=10000&key=" + KEY)
                .withHeaders(("Authorization", TOKEN), ("Content-Type", "application/json"))
                .get()
                .map { response =>
                    if (response.status == 403) {
                        None
                    }
                    else {
                        Some(response)
                    }
                }.recover {
                    case _: Throwable => {
                        None
                    }
                }
        }

        retry.Backoff(max = 4, delay = 5.seconds, base = 2)(getResultFuture).map {
            case None => {
                InternalServerError
            }
            case Some(response) => {
                Ok("Call successfull with status code: " + response.status)
            }
        }
    }
}

Depending on your use case, you’re not even required to use it exclusively with HTTP calls. Every future that might fail can be retried using dispatch.retry.

[1] https://developers.google.com/analytics/devguides/reporting/core/v3/coreErrors
[2] http://en.wikipedia.org/wiki/Exponential_backoff
[3] https://www.playframework.com/documentation/2.3.x/ScalaWS

← Back to Home

Matthieu Ravey
comments powered by Disqus