Conditional Caching with Retrofit and OkHttp

September 25, 2020

long-island.jpg

Let’s look at one way to implement caching for an HTTP request but also be able to bypass cache if necessary. This is useful if you know your remote endpoint does not change often so you are ok with cached HTTP response most of the time while allowing users to request fresh data from remote API and bypass cache.

cache.png

Let’s assume you have the following code setup:

  1. BookListViewModel

  2. BookRepository

  3. Book Dao

  4. Book Api

We can use forceRefresh boolean to control whether we’ll be reading from cache.

  • When forceRefresh is passed in, Cache-Control header will not be present in HTTP response and cache won't be used.

  • When forceRefresh is not passed in, Cache-Control: max-age=600 will be present in HTTP response and all subsequent request will be returning cached data (if there is a Cache hit).

BookListViewModel will contain the following (note the forceRefresh passed in down to repository):

fun getBooks(forceRefresh: Boolean) {
    viewModelScope.launch {
      bookRepository.fetchBooks(forceRefresh)
        .onStart {
          _viewState.value = BookListViewState.Loading
        }.catch { throwable ->
          _viewState.value = if (connectionHelper.isConnected()) {
            BookListViewState.Error(ErrorType.GENERIC)
          } else {
            BookListViewState.Error(ErrorType.CONNECTION)
          }
        }.collect { bookList ->
          _viewState.value = BookListViewState.Data(bookList)
        }
    }
  }

BookRepository will contain the following. Note that depending on the forceRefresh parameter we call api.fetchBooksForceRefresh() or api.fetchBooks()

class BookRepository(private val api: Api) {
  suspend fun fetchBooks(forceRefresh: Boolean = false): Flow<List<Book>> {
    return flow {
      emit(callApi(forceRefresh))
    }
  }
   
  private suspend fun callApi(forceRefresh: Boolean): List<Book> {
    return if (forceRefresh) {
      api.fetchBooksForceRefresh()
    } else {
      api.fetchBooks()
    }
  }
}

The Api is a Retrofit interface that has only two functions:

  1. api.fetchBooksForceRefresh()

  2. api.fetchBooks()

They need to be separate functions since one of them will need to be annotated to bypass caching.

Here is what it looks like:

const val CACHE_CONTROL_HEADER = "Cache-Control"
const val CACHE_CONTROL_NO_CACHE = "no-cache"

private const val API_KEY = BuildConfig.NYT_API_KEY
private const val FETCH_BOOKS_URL = "lists/current/health.json?api-key=$API_KEY"

interface Api {
  @GET(value = FETCH_BOOKS_URL)
  suspend fun fetchBooks(): List<Book>

  @GET(value = FETCH_BOOKS_URL)
  @Headers("$CACHE_CONTROL_HEADER: $CACHE_CONTROL_NO_CACHE")
  suspend fun fetchBooksForceRefresh(): List<Book>
}

When fetchBooksForceRefresh() is called, a header “Cache-Control: no-cache” will automatically be applied.

Note that the cache header can be controlled dynamically in Retrofit instead with @Header("Cache-Control") String cacheControl parameter. However, I decided against exposing this API to consumers—it could potentially lead to misuse. Instead, I think having a separate api function with a name clearly describing its purpose is a better approach.

And finally how do we set up cache and a mechanism for applying or bypassing cache?

Let’s assume we use Koin to manage injecting dependencies.

import okhttp3.Cache
import okhttp3.CacheControl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidApplication
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit

private const val BASE_URL = "https://api.nytimes.com/svc/books/v3/"

private const val CACHE_SIZE = 5 * 1024 * 1024L // 5 MB

val networkModule = module {
  single {
    httpCache(this.androidApplication())
  }

  single {
    okHttp(get())
  }

  single {
    retrofit(get())
  }

  single {
    get<Retrofit>().create(Api::class.java)
  }
}

private fun okHttp(cache: Cache): OkHttpClient {
  return OkHttpClient.Builder()
    .cache(cache)
    .addNetworkInterceptor(CacheInterceptor())
    .addInterceptor(loggingInterceptor())
    .build()
}

private val moshi = Moshi.Builder()
  .add(BooksJsonAdapter())
  .build()

private fun retrofit(okHttpClient: OkHttpClient) = Retrofit.Builder()
  .baseUrl(BASE_URL)
  .addConverterFactory(MoshiConverterFactory.create(moshi))
  .client(okHttpClient)
  .build()

private fun httpCache(application: Application): Cache {
  return Cache(application.applicationContext.cacheDir, CACHE_SIZE)
}

private fun loggingInterceptor() = HttpLoggingInterceptor().apply {
  level = HttpLoggingInterceptor.Level.HEADERS
}

class CacheInterceptor : Interceptor {
  override fun intercept(chain: Interceptor.Chain): Response {
    val request = chain.request()
    val originalResponse = chain.proceed(request)

    val shouldUseCache = request.header(CACHE_CONTROL_HEADER) != CACHE_CONTROL_NO_CACHE
    if (!shouldUseCache) return originalResponse

    val cacheControl = CacheControl.Builder()
      .maxAge(10, TimeUnit.MINUTES)
      .build()

    return originalResponse.newBuilder()
      .header(CACHE_CONTROL_HEADER, cacheControl.toString())
      .build()
  }
}

Pay attention to the CacheInterceptor class. For requests that have a header Cache-Control: no-cache, no new Cache-Control header will be added. Otherwise, header Cache-Control: max-age=600 will be passed in (600 seconds=10 minutes).

Under the hood, OkHttp uses LruCache which will start evicting least recently requested data when the cache is full to make room for new data (5MB cache capacity is reached in the above case).

The HttpLoggingInterceptor above logs HTTP headers. Try hitting the same endpoint with and without the cache control header and observe HTTP headers logged to confirm presence of the “Cache-Control” header and whether this was a cache hit or miss.

There are many ways to implement caching strategies when it comes to HTTP. The example above is just one way to do it.

Here is a Pull request example with this caching logic applied.

 
Previous
Previous

RecyclerView DiffUtil with Change Payload

Next
Next

Components and Scoping in Hilt