Migrating to Retrofit 2.0 from Volley

By Dandre Allison

When you work in a codebase that is a few years old and has plenty of code from before any of your current team members joined, you want to be conservative about the scope of changes you make in one feature—especially when that “feature” doesn’t add value to your product or get noticed by your end-users. We had been using Volley to handle our API requests, but the time came to migrate that client over to Retrofit. Our imagery was already handled by Picasso, so we didn’t have to do anything new even though Retrofit is doesn’t include any image handling logic.

There’s a huge conceptual difference between Volley and Retrofit, which is important to understand when dealing with the migration between the two libraries. This article will demonstrate changes we made to migrate to Retrofit, showcasing special features; it is not a deep comparison between Volley and Retrofit, nor an introduction to Retrofit 2.0. If you’re familiar with older versions of Retrofit, check out this article for a guide to what changed in 2.0.

Conceptual Differences

We have an abstraction layer between our underlying HTTP client and the rest of the app logic: HotelTonightClient. This class holds the reference to the underlying HTTP client and provides the interface used throughout our app to execute API requests. This means that HotelTonightClient is responsible for taking data parameters (e.g check-in date and location) and binding them to the appropriate HTTP client requests, along with the headers.

As an example, consider this method that retrieves our inventory:

public void getInventoryForMarket(Location location, Market market,
                                  int numNights, Date checkInDate,
                                  int collection, Callback callback) {
    String url = getV4ApiUrl() +
        String.format("/inventory?nights=%s", numNights);

    if (market != null) {
        url += String.format("&market=%s", market.getId());
    } else if (collection != Session.DEFAULT_COLLECTION) {
        url += String.format("&place=%d",        
            collection);
    }
    if (location != null) {
        url += String.format("&lat=%s&long=%s", 
            location.getLatitude(), location.getLongitude());
    }

    if (checkInDate != null) {
        String dateStr = getDateString(checkInDate, Locale.US, UTC));
        url += String.format("&check_in=%s", dateStr);
    }

    final String tag = callback.getTag();
    getRequestQueue().cancelAll(tag);
    Request<Inventory> request = VolleyRequest.createVolleyRequest(
        Method.GET, url, Inventory.class, getHeaders(), callback);
    request.setTag(tag);
    getRequestQueue().add(request);
}

This is one of three methods we have for requesting inventory. Some things to note:

Here’s a look at that same method after converting to Retrofit:

public void getInventoryForMarket(Location location, Market market,
                                  int numNights, Date checkInDate,
                                  int collection, Callback callback) {
    final Map<String, String> parameters = new ArrayMap<>(6);

    if (market != null) {
        parameters.put("market", market.getId());
    } else if (collection != Session.DEFAULT_COLLECTION) {
        parameters.put("place", Integer.toString(collection, 10));
    }
    if (location != null) {
        parameters.put("lat", location.getLatitude() + "");
        parameters.put("long", location.getLongitude() + "");
    }
    if (checkInDate != null) {
        String dateStr = getDateString(checkInDate, Locale.US, UTC);
        parameters.put("check_in", dateStr);
    }
    v4Api.inventory(numNights, parameters).enqueue(callback);
}

As you see, our focus has shifted from construct the correct URL to prepare the data. The v4Api#inventory method will handle the rest. No more &’s and ?’s to deal with!

We took advantage of both available formats for passing query parameters with Retrofit. We use @Query to explicitly declare “nights” because it is passed for every inventory request, and we use @QueryMap to pass key-value pairs for the optional query parameters that can be passed in various combinations.

Here’s the snippet from our v4 API interface that declares the v4Api#inventory method:

public interface HotelTonightApiV4 {

    @Get("v4/inventory")
    @Tag @Retry
    Call inventory(@Query("nights") int nights,
                   @QueryMap Map<String, String> parameters);
}

This is where things start to get interesting. Much of the logic from our URL-constructing getInventoryForMarket method has been replaced by declarative annotations:

  1. API versioning with @Get (provided by Retrofit)
  2. A new, custom @Tag annotation
  3. A new, custom @Retry annotation

API Versioning

As mentioned before, our requests are tied to different API versions. Retrofit handles implementing the API interfaces we define, such as HotelTonightApiV4, combining the relative URIs defined in the method annotations (“v4/inventory”) with a base URL defined when the interface implementations are instantiated. There are two ways to handle instantiating all of these API interfaces.

The first option is to use a different base URL for each API version (e.g. getV4ApiUrl() in the example above). In this case, the relative URI would be @Get("inventory").

The second option is to use a version-agnostic base URL (e.g. getApiUrl()). In this case, the relative URI would be @Get("v4/inventory").

We liked the second option for our core API, since we will be using the first option for other APIs we use:

final Retrofit apiRetrofit = retrofitBuilder
        .baseUrl(new ApiEndpoint())
        .build();
v3Api = apiRetrofit.create(HotelTonightApiV3.class);
v4Api = apiRetrofit.create(HotelTonightApiV4.class);

This is a stylistic decision: we will already need new Retrofit objects to instantiate the other APIs that we use, such as an Analytics tracking API. When creating those other Retrofit objects, make sure to reuse the same Retrofit.Builder. Specifically, make sure to call Retrofit.Builder#client on this shared Retrofit.Builder. Doing so prevents the builder from creating a new client instance for each interface implementation, which would be the only negative overhead of creating several interface implementations.

We are also able to set the base URL dynamically using Retrofit’s BaseUrl interface. Instead of using getApiUrl(), we can point to a new ApiEndpoint() object that resolves getApiUrl() dynamically:

class ApiEndpoint implements BaseUrl {
    @Override
    public HttpUrl url() { return HttpUrl.parse(getApiUrl()); }
}

Call Adapting

Retrofit’s service approach allows us to augment the objects created by the interface implementations, injecting additional functionality into the data flow.

We added .addCallAdapterFactory(TagCancellableCallAdapterFactory.create()) to our Retrofit.Builder, which registers to inject our cancelation logic into the Call object. While the Call class has a Call#cancel method, it requires a reference to the Call object and logic to know when to execute the cancelation. Since this logic is injected directly into the Retrofit data flow, this functionality can be applied throughout the app using the @Tag annotation with no additional management.

class TagCancellableCallAdapterFactory implements CallAdapter.Factory {
    // References to the last Call made for a given tag
    private final ArrayMap<String, Call> mQueuedCalls;

    private TagCancellableCallAdapterFactory() {
        mQueuedCalls = new ArrayMap<>(2);
    }

    public static TagCancellableCallAdapterFactory create() {
        return new TagCancellableCallAdapterFactory();
    }

    @Override
    public CallAdapter<?> get(Type returnType,
                              Annotation[] annotations,
                              Retrofit retrofit) {
        boolean hasTagAnnotation = false;
        String value = StringUtils.EMPTY;
        for (Annotation annotation : annotations) {
            // Checks if method registers to use cancelation logic
            // Extracts the relative URI from Retrofit annotations
            if (annotation instanceof Tag) {
                hasTagAnnotation = true;
            } else if (annotation instanceof DELETE) {
                value = ((DELETE) annotation).value();
            } else if (annotation instanceof GET) {
                value = ((GET) annotation).value();
            } else if (annotation instanceof HEAD) {
                value = ((HEAD) annotation).value();
            } else if (annotation instanceof PATCH) {
                value = ((PATCH) annotation).value();
            } else if (annotation instanceof POST) {
                value = ((POST) annotation).value();
            } else if (annotation instanceof PUT) {
                value = ((PUT) annotation).value();
            }
        }
        final boolean isTagged = hasTagAnnotation;
        final String tag = value;
        // Delegates work to default behavior, this is how the logic
        // gets injected into the rest of the Retrofit data flow
        CallAdapter<?> delegate = retrofit.nextCallAdapter(this, 
                returnType, annotations);
        // Executor that will execute the cancelations
        final ExecutorService executor = retrofit.client()
                .getDispatcher().getExecutorService();
        return new CallAdapter<Object>() {
            @Override
            public Type responseType() {
                return delegate.responseType();
            }

            @Override
            public <R> Object adapt(Call<R> call) {
                // Only @Tag methods will use TaggedCall
                return delegate.adapt(isTagged ?
                new TaggedCall<>(call, tag, mQueuedCalls, executor) :
                call);
            }
        };
    }

    static final class TaggedCall<T> implements Call<T> {
        private final Call<T> mDelegate;
        private final String mTag;
        private final ArrayMap<String, Call> mQueuedCalls;
        private final ExecutorService mExecutor;

        TaggedCall(Call<T> delegate, String tag,
                   ArrayMap<String, Call> queuedCalls,
                   ExecutorService executor) {
            mQueuedCalls = queuedCalls;
            mTag = tag;
            mDelegate = delegate;
            mExecutor = executor;
        }

        @Override
        public Response<T> execute() throws IOException {
            return mDelegate.execute();
        }

        @Override
        public void enqueue(Callback<T> callback) {
            synchronized (mQueuedCalls) {
                // Cancel enqueued call for the same tag
                if (mQueuedCalls.containsKey(mTag)) {
                    final Call queuedCall = mQueuedCalls.get(mTag);
                    if (queuedCall != null) {
                        // https://github.com/square/okhttp/issues/1592
                        // Call.cancel() is triggering StrictMode
                        mExecutor.execute(new Runnable() {
                            @Override
                            public void run() {
                                queuedCall.cancel();
                            }
                        });
                    }
                    mQueuedCalls.remove(mTag);
                }
                // Add call to enqueued calls
                mQueuedCalls.put(mTag, mDelegate);
            }
            mDelegate.enqueue(callback);
        }

        @Override
        public void cancel() {
            mDelegate.cancel();
        }

        @SuppressWarnings("CloneDoesntCallSuperClone")
        @Override
        public Call<T> clone() {
            return new TaggedCall<>(mDelegate.clone(), mTag,
                    mQueuedCalls, mExecutor);
        }
    }
}

With this CallAdapter, we are able to decorate the Call object with logic to tag the Call, and cancel any previous Calls with the same tag, before executing the Call. All this complex behavior gets enabled by the @Tag annotation.

Request Retrying

Using the service approach once more, we were able to add request retry functionality to Retrofit. This one is based on the @Retry annotation:

public class RetryCallAdapterFactory implements CallAdapter.Factory {
    // Executor that will schedule the retry attempts
    private final ScheduledExecutorService mExecutor;

    private RetryCallAdapterFactory() {
        mExecutor = Executors.newScheduledThreadPool(1);
    }

    public static RetryCallAdapterFactory create() {
        return new RetryCallAdapterFactory();
    }

    @Override
    public CallAdapter<?> get(final Type returnType,
                              Annotation[] annotations,
                              Retrofit retrofit) {
        boolean hasRetryAnnotation = false;
        int value = 0;
        // Checks for the @Retry annotation and declared # retries
        for (Annotation annotation : annotations) {
            if (annotation instanceof Retry) {
                hasRetryAnnotation = true;
                value = ((Retry) annotation).value();
            }
        }
        final boolean shouldRetryCall = hasRetryAnnotation;
        final int maxRetries = value;
        CallAdapter<?> delegate = retrofit.nextCallAdapter(this,
                returnType, annotations);
        return new CallAdapter<Object>() {
            @Override
            public Type responseType() {
                return delegate.responseType();
            }

            @Override
            public <R> Object adapt(Call<R> call) {
                // Only @Retry methods will use RetryingCall
                return delegate.adapt(shouldRetryCall ?
                    new RetryingCall<>(call, mExecutor, maxRetries) :
                    call);
            }
        };
    }

    static final class RetryingCall<T> implements Call<T> {
        private final Call<T> mDelegate;
        private final ScheduledExecutorService mExecutor;
        private final int mMaxRetries;

        public RetryingCall(Call<T> delegate,
                            ScheduledExecutorService executor,
                            int maxRetries) {
            mDelegate = delegate;
            mExecutor = executor;
            mMaxRetries = maxRetries;
        }

        @Override
        public Response<T> execute() throws IOException {
            return mDelegate.execute();
        }

        @Override
        public void enqueue(Callback<T> callback) {
            // Decorates the given Callback with retrying logic
            mDelegate.enqueue(new RetryingCallback<>(mDelegate,
                    callback, mExecutor, mMaxRetries));
        }

        @Override
        public void cancel() {
            mDelegate.cancel();
        }

        @SuppressWarnings("CloneDoesntCallSuperClone")
        @Override
        public Call<T> clone() {
            return new RetryingCall<>(mDelegate.clone(), mExecutor, 
                    mMaxRetries);
        }
    }

    // Exponential backoff approach from
    // https://developers.google.com/drive/web/handle-errors
    static final class RetryingCallback<T> implements Callback<T> {
        private static Random random = new Random();
        private final int mMaxRetries;
        private final Call<T> mCall;
        private final Callback<T> mDelegate;
        private final ScheduledExecutorService mExecutor;
        private final int mRetries;

        RetryingCallback(Call<T> call, Callback<T> delegate,
                         ScheduledExecutorService executor,
                         int maxRetries) {
            this(call, delegate, executor, maxRetries, 0);
        }

        RetryingCallback(Call<T> call, Callback<T> delegate,
                         ScheduledExecutorService executor,
                         int maxRetries, int retries) {
            mCall = call;
            mDelegate = delegate;
            mExecutor = executor;
            mMaxRetries = maxRetries;
            mRetries = retries;
        }

        @Override
        public void onResponse(Response<T> response,
                               Retrofit retrofit) {
            mDelegate.onResponse(response, retrofit);
        }

        @Override
        public void onFailure(Throwable throwable) {
            // Retry failed request
            if (mRetries < mMaxRetries) {
                retryCall();
            } else {
                mDelegate.onFailure(new TimeoutError(throwable));
            }
        }

        private void retryCall() {
            mExecutor.schedule(new Runnable() {
                @Override
                public void run() {
                    // Calls can only be enqueued once
                    final Call<T> call = mCall.clone();
                    call.enqueue(new RetryingCallback<>(call,
                            mDelegate, mExecutor, mMaxRetries,
                            mRetries + 1));
                }
            },
            (1 << mRetries) * 1000 + random.nextInt(1001), MILLIS);
        }
    }
}

This way we are able to add functionality to our Callbacks while maintaining simplicity in the downstream execution of requests. Aside from the introduction of a TimeoutError, which allows us to display proper user messaging, requests will look identical to the rest of the application regardless of whether they were retried.

In Closing

Hopefully this provides a starting point to exercise the full potential of the service approach in Retrofit 2.0. We’ve seen how we can extend the Call or the Callback—or both, naturally—in ways that separate certain concerns from the rest of the app, and allow declarative use of that functionality. It will be exciting to see what more comes out of this approach; this example for adapting the RxJava Observable type Retrofit Adapter is a great place to continue reading. And if you enjoy working on interesting problems such as this one, we’re hiring at HotelTonight.

Written by Dandre Allison

Read more posts by Dandre, and follow Dandre on Twitter.

Interested in building something great?

Join us in building the worlds most loved hotel app.
View our open engineering positions.