1

I need to implement a catch when an error occur like network related or the API limit has been reached. Now I've seen a lot of good samples yet it seems they are missing something.

This tutorial the handling of cache is based the network status like if a mobile data or Wi-Fi of the device is on/off. It is not the solution since one can be connected to a network but the network has no data or no internet at all.

The ideal flow on me would be

  • Cache every time a fetch is success which repeats every 5 seconds.
  • Use only the last cached data available if any of the performed fetch failed, if no error then use the fresh data set from online response.
  • The cached data can be available for days or weeks and will only be updated again every new fetch is success.
  • If no cache yet is available and the very first fetch was failed only then show the error.

My code

interface EndpointServices {

    companion object {

        private fun interceptor(): Interceptor {
        return Interceptor { chain ->
            var request: Request = chain.request()
            val originalResponse: Response = chain.proceed(request)
            val cacheControl: String? = originalResponse.header("Cache-Control")
            if (cacheControl == null || cacheControl.contains("no-store") || cacheControl.contains("no-cache") ||
                cacheControl.contains("must-revalidate") || cacheControl.contains("max-stale=0")
            ) {
                Log.wtf("INTERCEPT", "SAVE A CACHE")
                val cc: CacheControl = CacheControl.Builder()
                    .maxStale(1, TimeUnit.DAYS)
                    .build()
                request = request.newBuilder()
                    .removeHeader("Pragma")
                    .header("Cache-Control", "public")
                    .cacheControl(cc)
                    .build()
                chain.proceed(request)
            } else {
                Log.wtf("INTERCEPT", "ONLINE FETCH")
                originalResponse.newBuilder()
                    .removeHeader("Pragma")
                    .build()
            }
        }
    }


    private fun onlineOfflineHandling(): Interceptor {
        return Interceptor { chain ->
            try {
                Log.wtf("INTERCEPT", "TRY ONLINE")
                chain.proceed(chain.request())
            } catch (e: Exception) {
                Log.wtf("INTERCEPT", "FALLBACK TO CACHE")

                val cacheControl: CacheControl = CacheControl.Builder()
                    .maxStale(1, TimeUnit.DAYS)
                    .onlyIfCached() //Caching condition
                    .build()

                val offlineRequest: Request = chain.request().newBuilder()
                    .cacheControl(cacheControl)
                    .build()
                chain.proceed(offlineRequest)
            }
        }
    }


        fun create(baseUrl: String, context: Context): EndpointServices {

        val cacheSize: Long = 10 * 1024 * 1024 // 10 MB

        val cache = Cache(context.cacheDir, cacheSize)

        val interceptor = HttpLoggingInterceptor()
        interceptor.level = HttpLoggingInterceptor.Level.BODY

        val httpClient = OkHttpClient.Builder()
            .cache(cache)
            .addInterceptor(interceptor)
            .callTimeout(10, TimeUnit.SECONDS)
            .connectTimeout(10, TimeUnit.SECONDS)
            .addNetworkInterceptor(interceptor())
            .addInterceptor(onlineOfflineHandling())
            .build()

        val retrofit = Retrofit.Builder()
            .addCallAdapterFactory(
                RxJava2CallAdapterFactory.create()
            )
            .addConverterFactory(
                MoshiConverterFactory.create()
            )
            .client(httpClient)
            .baseUrl(baseUrl)
            .build()

        return retrofit.create(EndpointServices::class.java)

    }

}

Main activity

intervalDisposable = Observable.interval(0L, 5L, TimeUnit.SECONDS)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe {
                    Log.d("Interval", it.toString())
                    fetchAssets(UriUtil.assetField, "30")
                }



    private fun fetchAssets(field: String, limit: String) {
            disposable = EndpointServices.create(url, requireContext()).getAssetItems(
                field,
                limit
            )
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(
                    { result ->
                        //Our response here

                    },
                    { error ->
                        //Error, offline and no cache has been found
                        Log.wtf("WTF", "${error.message}")
                        Toast.makeText(context, error.message, Toast.LENGTH_LONG).show()
                    }
                )
        }

I am working with @GET

UPDATES Apr 10 2021

I tried to work with Youtube API as an example and this is what the test result.

Mobile Data/Wi-Fi off

  • INTERCEPT: TRY ONLINE
  • INTERCEPT: FALLBACK TO CACHE
  • The response was return (it works!)

Wi-Fi on and connected to a network but has no data/internet connection service

  • INTERCEPT: TRY ONLINE
  • Waiting for response to timeout?
  • INTERCEPT: FALLBACK TO CACHE
  • No response was return (WTF?)

Mobile data/Wi-Fi on and internet service is available (Online) works on Youtube API only so far

  • INTERCEPT: TRY ONLINE
  • INTERCEPT: ONLINE FETCH
  • The response was return (it works!)

I tried working on other APIs too but no luck so far only YouTube API works but not as intended yet. I need an approach that could work on almost any API.

UPDATES Apr 11 2021

I updated the code and somewhat managed to make it work almost to what we needed.

interface EndpointServices {

    companion object {

        private fun interceptor(): Interceptor {
            return Interceptor { chain ->
                val request: Request = chain.request()
                val originalResponse: Response = chain.proceed(request)
                val cacheControlStatus: String? = originalResponse.header("Cache-Control")
                if (cacheControlStatus == null || cacheControlStatus.contains("no-store") || cacheControlStatus.contains(
                        "no-cache") ||
                    cacheControlStatus.contains("must-revalidate") || cacheControlStatus.contains("max-stale=0")
                ) {

                    Log.wtf("INTERCEPT", "ORIGINAL CACHE-CONTROL: $cacheControlStatus")

                } else {

                    Log.wtf("INTERCEPT",
                        "ORIGINAL : CACHE-CONTROL: $cacheControlStatus")

                }

                Log.wtf("INTERCEPT",
                    "OVERWRITE CACHE-CONTROL: ${request.cacheControl} | CACHEABLE? ${
                        CacheStrategy.isCacheable(originalResponse,
                            request)
                    }")

                originalResponse.newBuilder()
                    .build()

            }
        }


        private fun onlineOfflineHandling(): Interceptor {
        return Interceptor { chain ->
            try {
                Log.wtf("INTERCEPT", "FETCH ONLINE")
                val cacheControl = CacheControl.Builder()
                    .maxAge(5, TimeUnit.SECONDS)
                    .build()

                val response = chain.proceed(chain.request().newBuilder()
                    .removeHeader("Pragma")
                    .removeHeader("Cache-Control")
                    .header("Cache-Control", "public, $cacheControl")
                    .build())

                Log.wtf("INTERCEPT", "CACHE ${response.cacheResponse} NETWORK ${response.networkResponse}")

                response
            } catch (e: Exception) {
                Log.wtf("INTERCEPT", "FALLBACK TO CACHE ${e.message}")

                val cacheControl: CacheControl = CacheControl.Builder()
                    .maxStale(1, TimeUnit.DAYS)
                    .onlyIfCached() // Use Cache if available
                    .build()

                val offlineRequest: Request = chain.request().newBuilder()
                    .cacheControl(cacheControl)
                    .build()

                val response = chain.proceed(offlineRequest)

                Log.wtf("INTERCEPT", "CACHE ${response.cacheResponse} NETWORK ${response.networkResponse}")

                response
            }
        }
    }


        fun create(baseUrl: String, context: Context): EndpointServices {

            // Inexact 150 MB of maximum cache size for a total of 4000 assets where about 1MB/30 assets
            // The remaining available space will be use for other cacheable requests
            val cacheSize: Long = 150 * 1024 * 1024

            val cache = Cache(context.cacheDir, cacheSize)

            Log.wtf("CACHE DIRECTORY", cache.directory.absolutePath)

            for (cacheUrl in cache.urls())
                Log.wtf("CACHE URLS", cacheUrl)

            Log.wtf("CACHE OCCUPIED/TOTAL SIZE", "${cache.size()} ${cache.maxSize()}")

            val interceptor = HttpLoggingInterceptor()
            interceptor.level = HttpLoggingInterceptor.Level.BODY

            val httpClient = OkHttpClient.Builder()
                .cache(cache)
                .addInterceptor(interceptor)
                .callTimeout(10, TimeUnit.SECONDS)
                .connectTimeout(10, TimeUnit.SECONDS)
                .addNetworkInterceptor(interceptor())
                .addInterceptor(onlineOfflineHandling())
                .build()

            val retrofit = Retrofit.Builder()
                .addCallAdapterFactory(
                    RxJava2CallAdapterFactory.create()
                )
                .addConverterFactory(
                    MoshiConverterFactory.create()
                )
                .client(httpClient)
                .baseUrl(baseUrl)
                .build()

            return retrofit.create(EndpointServices::class.java)

        }

    }

    @GET("search")
    fun getVideoItems(
        @Query("key") key: String,
        @Query("part") part: String,
        @Query("maxResults") maxResults: String,
        @Query("order") order: String,
        @Query("type") type: String,
        @Query("channelId") channelId: String,
    ):
            Single<VideoItemModel>


}

MainActivity

EndpointServices.create(url, requireContext()).getVideoItems(
            AppUtils.videoKey,
            "id,snippet",
            "20",
            "date",
            "video",
            channelId
        )
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(
                { result ->

                    Log.wtf("RESPONSE", result.toString())
                    adapter.submitList(result.videoData)

                    swipeRefreshLayout.isRefreshing = false

                    logTxt.text = null

                },
                { error ->
                    Log.wtf("WTF", "${error.message}")
                    swipeRefreshLayout.isRefreshing = false
                    if (adapter.currentList.isEmpty() || (error is HttpException && error.code() == HttpURLConnection.HTTP_GATEWAY_TIMEOUT)){
                        adapter.submitList(mutableListOf())
                        logTxt.text = getString(R.string.swipeToRefresh)
                    }
                }
            )

FLOW BASED ON LOGS

WHEN ONLINE

A/CACHE DIRECTORY: /data/data/com.appname.app/cache
A/CACHE URLS: www.api.com
A/CACHE OCCUPIED/TOTAL SIZE: 228982 157286400
A/INTERCEPT: FETCH ONLINE
A/INTERCEPT: ORIGINAL : CACHE-CONTROL: private
A/INTERCEPT: OVERWRITE CACHE-CONTROL: public, max-age=5 | CACHEABLE? true
A/INTERCEPT: CACHE Response{protocol=http/1.1, code=200, message=, url=https://api.com} NETWORK Response{protocol=h2, code=304, message=, url=https://api.com}
A/RESPONSE: VideoItemModel(.....) WORKING!

COMPLETELY OFFLINE (Wi-Fi/Mobile Data OFF)

A/CACHE DIRECTORY: /data/data/com.appname.app/cache
A/CACHE URLS: www.api.com
A/CACHE OCCUPIED/TOTAL SIZE: 228982 157286400
A/INTERCEPT: FETCH ONLINE
A/INTERCEPT: FALLBACK TO CACHE Unable to resolve host "api.com": No address associated with hostname
A/INTERCEPT: CACHE Response{protocol=http/1.1, code=200, message=, url=https://api.com} NETWORK null
A/RESPONSE: VideoItemModel(.....) WORKING!

JUST CONNECTED TO A NETWORK BUT REALLY NO INTERNET SERVICE (Wi-Fi/Mobile Data ON)

A/CACHE DIRECTORY: /data/data/com.appname.app/cache
A/CACHE URLS: www.api.com
A/CACHE OCCUPIED/TOTAL SIZE: 228982 157286400
A/INTERCEPT: FETCH ONLINE
A/INTERCEPT: FALLBACK TO CACHE Unable to resolve host "api.com": No address associated with hostname
???WHERE IS THE CALLBACK JUST LIKE THE PREVIOUS ONE???

Also worth mentioning that neither of the line Log.wtf("INTERCEPT", "CACHE ${response.cacheResponse} NETWORK ${response.networkResponse}") is being called on this last scenario.

Mihae Kheel
  • 2,441
  • 3
  • 14
  • 38
  • As an example this isn't really approachable to provide assistance. For specific requirements like this providing a sample project or a single file with a main method would make it a lot simpler for others to assist you. – Yuri Schimke Apr 10 '21 at 09:58
  • @YuriSchimke updated, weird cause as I recall I separate the interface code to activity code yesterday. Anyway I've been able to work with some API like with Youtube where the device data/wifi is off but working on other API does not work. Am I missing some configuration with CacheControl here? It seems some API response can't be cache with the code above. – Mihae Kheel Apr 10 '21 at 11:44
  • @YuriSchimke is my understanding on the flow is correct base on the Logs? – Mihae Kheel Apr 10 '21 at 12:01
  • One other wild stab at this. Make sure you are cleanly closing any prior responses if you create a new request in an interceptor. – Yuri Schimke Apr 11 '21 at 11:49
  • @YuriSchimke how to know when those response was closed before each request? – Mihae Kheel Apr 11 '21 at 13:13
  • If your interceptor calls chain.proceed but does not return that value, then you become responsible for closing it. – Yuri Schimke Apr 11 '21 at 14:24
  • Based on the given code above where could I cancel it? Also what are the draw backs if not closing it? A deadlock? – Mihae Kheel Apr 12 '21 at 08:30

1 Answers1

0

Not an answer yet, but a standalone example that might help debug this. From the example you've provided it's hard to tell if there is a logic problem in your code.

#!/usr/bin/env kotlin

@file:Repository("https://repo1.maven.org/maven2/")
@file:DependsOn("com.squareup.okhttp3:okhttp:4.9.1")
@file:DependsOn("com.squareup.okhttp3:logging-interceptor:4.9.1")
@file:CompilerOptions("-jvm-target", "1.8")

// https://stackoverflow.com/a/66364994/1542667

import okhttp3.Cache
import okhttp3.Dns
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.LoggingEventListener
import java.io.File
import java.net.InetAddress
import java.net.UnknownHostException

val cache = Cache(File("/tmp/tmpcache.2"), 100 * 1024 * 1024)

val systemDns = Dns.SYSTEM
var failDns = false

val client = OkHttpClient.Builder()
  .eventListenerFactory(LoggingEventListener.Factory { println(it) })
  .cache(cache)
  .dns(object : Dns {
    override fun lookup(hostname: String): List<InetAddress> {
      if (failDns) {
        throw UnknownHostException("NO HOST")
      }

      return systemDns.lookup(hostname)
    }
  })
  .build()

val request = Request.Builder()
  .url("https://raw.github.com/square/okhttp/master/README.md")
  .build()

makeRequest()
failDns = true
makeRequest()

fun makeRequest() {
  client.newCall(request).execute().use {
    println(it.headers)
    println("cache ${it.cacheResponse} network ${it.networkResponse}")
    println(it.body!!.string().lines().first())
  }
}

This example will run directly in Intellij or from the command line if you have kotlin installed. https://stackoverflow.com/a/66364994/1542667

Output

[0 ms] callStart: Request{method=GET, url=https://raw.github.com/square/okhttp/master/README.md}
[19 ms] cacheMiss
[20 ms] proxySelectStart: https://raw.github.com/
[21 ms] proxySelectEnd: [DIRECT]
[21 ms] dnsStart: raw.github.com
[64 ms] dnsEnd: [raw.github.com/185.199.109.133, raw.github.com/185.199.110.133, raw.github.com/185.199.111.133, raw.github.com/185.199.108.133]
[73 ms] connectStart: raw.github.com/185.199.109.133:443 DIRECT
[105 ms] secureConnectStart
[293 ms] secureConnectEnd: Handshake{tlsVersion=TLS_1_3 cipherSuite=TLS_AES_256_GCM_SHA384 peerCertificates=[CN=www.github.com, O="GitHub, Inc.", L=San Francisco, ST=California, C=US, CN=DigiCert SHA2 High Assurance Server CA, OU=www.digicert.com, O=DigiCert Inc, C=US, CN=DigiCert High Assurance EV Root CA, OU=www.digicert.com, O=DigiCert Inc, C=US] localCertificates=[]}
[365 ms] connectEnd: h2
[368 ms] connectionAcquired: Connection{raw.github.com:443, proxy=DIRECT hostAddress=raw.github.com/185.199.109.133:443 cipherSuite=TLS_AES_256_GCM_SHA384 protocol=h2}
[369 ms] requestHeadersStart
[372 ms] requestHeadersEnd
[721 ms] responseHeadersStart
[723 ms] responseHeadersEnd: Response{protocol=h2, code=301, message=, url=https://raw.github.com/square/okhttp/master/README.md}
[726 ms] responseBodyStart
[726 ms] responseBodyEnd: byteCount=0
[771 ms] cacheMiss
[771 ms] connectionReleased
[771 ms] proxySelectStart: https://raw.githubusercontent.com/
[772 ms] proxySelectEnd: [DIRECT]
[772 ms] dnsStart: raw.githubusercontent.com
[797 ms] dnsEnd: [raw.githubusercontent.com/185.199.111.133, raw.githubusercontent.com/185.199.108.133, raw.githubusercontent.com/185.199.109.133, raw.githubusercontent.com/185.199.110.133]
[799 ms] connectionAcquired: Connection{raw.github.com:443, proxy=DIRECT hostAddress=raw.github.com/185.199.109.133:443 cipherSuite=TLS_AES_256_GCM_SHA384 protocol=h2}
[799 ms] requestHeadersStart
[800 ms] requestHeadersEnd
[980 ms] responseHeadersStart
[980 ms] responseHeadersEnd: Response{protocol=h2, code=200, message=, url=https://raw.githubusercontent.com/square/okhttp/master/README.md}
cache-control: max-age=300
content-security-policy: default-src 'none'; style-src 'unsafe-inline'; sandbox
content-type: text/plain; charset=utf-8
etag: W/"846e6af5d55b29262841dbd93b02a95ff38f8709b68aa782be13f29d094a5421"
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
x-frame-options: deny
x-xss-protection: 1; mode=block
x-github-request-id: F08E:5C09:CAD563:D4D764:6072B974
accept-ranges: bytes
date: Sun, 11 Apr 2021 09:52:07 GMT
via: 1.1 varnish
x-served-by: cache-lon4280-LON
x-cache: HIT
x-cache-hits: 1
x-timer: S1618134728.761197,VS0,VE155
vary: Authorization,Accept-Encoding
access-control-allow-origin: *
x-fastly-request-id: da78b4491988420875d181584295baef3b3f3a6d
expires: Sun, 11 Apr 2021 09:57:07 GMT
source-age: 0

cache null network Response{protocol=h2, code=200, message=, url=https://raw.githubusercontent.com/square/okhttp/master/README.md}
[1007 ms] responseBodyStart
[1007 ms] responseBodyEnd: byteCount=2747
[1007 ms] connectionReleased
[1007 ms] callEnd
OkHttp
[0 ms] callStart: Request{method=GET, url=https://raw.github.com/square/okhttp/master/README.md}
[15 ms] cacheConditionalHit: Response{protocol=http/1.1, code=301, message=, url=https://raw.github.com/square/okhttp/master/README.md}
[15 ms] connectionAcquired: Connection{raw.github.com:443, proxy=DIRECT hostAddress=raw.github.com/185.199.109.133:443 cipherSuite=TLS_AES_256_GCM_SHA384 protocol=h2}
[15 ms] requestHeadersStart
[15 ms] requestHeadersEnd
[35 ms] responseHeadersStart
[35 ms] responseHeadersEnd: Response{protocol=h2, code=301, message=, url=https://raw.github.com/square/okhttp/master/README.md}
[35 ms] responseBodyStart
[35 ms] responseBodyEnd: byteCount=0
[42 ms] cacheMiss
[52 ms] cacheHit: Response{protocol=http/1.1, code=200, message=, url=https://raw.githubusercontent.com/square/okhttp/master/README.md}
[52 ms] connectionReleased
[52 ms] callEnd
cache-control: max-age=300
content-security-policy: default-src 'none'; style-src 'unsafe-inline'; sandbox
content-type: text/plain; charset=utf-8
etag: W/"846e6af5d55b29262841dbd93b02a95ff38f8709b68aa782be13f29d094a5421"
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
x-frame-options: deny
x-xss-protection: 1; mode=block
x-github-request-id: F08E:5C09:CAD563:D4D764:6072B974
accept-ranges: bytes
date: Sun, 11 Apr 2021 09:52:07 GMT
via: 1.1 varnish
x-served-by: cache-lon4280-LON
x-cache: HIT
x-cache-hits: 1
x-timer: S1618134728.761197,VS0,VE155
vary: Authorization,Accept-Encoding
access-control-allow-origin: *
x-fastly-request-id: da78b4491988420875d181584295baef3b3f3a6d
expires: Sun, 11 Apr 2021 09:57:07 GMT
source-age: 0

cache Response{protocol=http/1.1, code=200, message=, url=https://raw.githubusercontent.com/square/okhttp/master/README.md} network null
OkHttp
Yuri Schimke
  • 12,435
  • 3
  • 35
  • 69
  • Adding `.dns(object : Dns { //... }` makes it from `A/INTERCEPT: FALLBACK TO CACHE Unable to resolve host` to `A/INTERCEPT: FALLBACK TO CACHE Canceled` but still no callback on response/result. – Mihae Kheel Apr 11 '21 at 10:21
  • I am recieving the cache very well with mobile data/wifi completely off `A/INTERCEPT: CACHE Response{protocol=http/1.1, code=200, message=, url=https://api.com} NETWORK null ` – Mihae Kheel Apr 11 '21 at 10:33
  • In my observation it has something to do on how OkHttp3 or Retrofit2 work. I can't see any related question at all about this. – Mihae Kheel Apr 11 '21 at 10:34
  • My goal here is to be able to reproduce the problem in order to fix it or understand what is going wrong. From the code sample you posted it isn't a reproducible form as is. Can you try running the sample I provided and adapt it to make it fail as you observe. – Yuri Schimke Apr 11 '21 at 10:42
  • However when I tried an online request Cache is not null as well `CACHE Response{protocol=http/1.1, code=200, message=, url=https://api.com} NETWORK Response{protocol=h2, code=304, message=, url=https://api.com}` – Mihae Kheel Apr 11 '21 at 10:43
  • I understood since you stated it many many times, but I think the above code is very simple sample already. It can be tested on any sample app just use a different data class for Observable and use any API for testing purposes? – Mihae Kheel Apr 11 '21 at 10:48
  • OK, well I hope you manage to debug the issue and resolve it, I'll bookmark this and see how it goes. – Yuri Schimke Apr 11 '21 at 10:50
  • Thanks mate, also if you can refer someone as well on this question I am pleased. I also updated the question for the progress just now free to check it again. – Mihae Kheel Apr 11 '21 at 11:04