Conditional Caching with Retrofit and OkHttp
September 25, 2020
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.
Let’s assume you have the following code setup:
BookListViewModel
BookRepository
Book Dao
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:
api.fetchBooksForceRefresh()
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.