Retrofit2 和 OkHttp3 仅在发生错误时使用缓存,例如网络错误或达到配额限制

Posted

技术标签:

【中文标题】Retrofit2 和 OkHttp3 仅在发生错误时使用缓存,例如网络错误或达到配额限制【英文标题】:Retrofit2 and OkHttp3 use cache only when error occur such as Network errors or quota limit reach 【发布时间】:2021-07-04 04:59:31 【问题描述】:

当出现与网络相关的错误或已达到 API 限制时,我需要执行 catch。现在我看到了很多很好的样本,但它们似乎缺少一些东西。

tutorial 缓存的处理基于网络状态,例如设备的移动数据或 Wi-Fi 是否打开/关闭。这不是解决方案,因为可以连接到网络,但网络没有数据或根本没有互联网。

对我来说理想的流程是

每次获取成功时缓存,每 5 秒重复一次。 如果执行了任何提取,则仅使用最后可用的缓存数据 失败,如果没有错误,则使用来自在线响应的新数据集。 缓存的数据可以在几天或几周内可用,并且只会在 每次新的抓取成功都会再次更新。 如果还没有可用的缓存并且第一次提取失败,则仅显示错误。

我的代码

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)

    


主要活动

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()
                    
                )
        

我正在使用@GET

2021 年 4 月 10 日更新

我尝试使用 Youtube API 作为示例,这就是测试结果。

移动数据/Wi-Fi 关闭

拦截:在线尝试 拦截:回退到缓存 响应是返回(有效!)

Wi-Fi 已打开并已连接到网络,但没有数据/互联网连接服务

拦截:在线尝试 等待响应超时? 拦截:回退到缓存 没有返回响应(WTF?)

移动数据/Wi-Fi 和互联网服务可用(在线) 目前仅适用于 Youtube API

拦截:在线尝试 拦截:在线提取 响应是返回(有效!)

我也尝试过使用其他 API,但到目前为止没有运气 只有 YouTube API 可以工作,但还没有达到预期的效果。我需要一种几乎适用于任何 API 的方法。

2021 年 4 月 11 日更新

我更新了代码,并在某种程度上设法使它几乎可以满足我们的需要。

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>



主活动

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)
                    
                
            )

基于日志的流程

在线时

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 Responseprotocol=http/1.1, code=200, message=, url=https://api.com NETWORK Responseprotocol=h2, code=304, message=, url=https://api.com
A/RESPONSE: VideoItemModel(.....) WORKING!

完全离线(Wi-Fi/移动数据关闭)

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 Responseprotocol=http/1.1, code=200, message=, url=https://api.com NETWORK null
A/RESPONSE: VideoItemModel(.....) WORKING!

刚刚连接到网络,但实际上没有互联网服务(Wi-Fi/移动数据开启)

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???

另外值得一提的是,这两行都没有 Log.wtf("INTERCEPT", "CACHE $response.cacheResponse NETWORK $response.networkResponse") 在最后一个场景中被调用。

【问题讨论】:

举个例子,提供帮助并不容易。对于像这样的特定要求,提供示例项目或带有 main 方法的单个文件将使其他人更容易为您提供帮助。 @YuriSchimke 更新了,奇怪的原因我记得我昨天将界面代码与活动代码分开。无论如何,我已经能够使用一些 API,例如 Youtube,其中设备数据/wifi 已关闭,但使用其他 API 不起作用。我在这里缺少 CacheControl 的一些配置吗?上面的代码似乎无法缓存某些 API 响应。 @YuriSchimke 根据日志,我对流程的理解是否正确? 又一次疯狂的尝试。如果您在拦截器中创建新请求,请确保您干净地关闭任何先前的响应。 @YuriSchimke 如何知道这些响应在每次请求之前何时关闭? 【参考方案1】:

目前还不是答案,而是一个可能有助于调试的独立示例。从您提供的示例中,很难判断您的代码中是否存在逻辑问题。

#!/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://***.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())
  

如果您安装了 kotlin,此示例将直接在 Intellij 中运行或从命令行运行。 https://***.com/a/66364994/1542667

输出

[0 ms] callStart: Requestmethod=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: HandshaketlsVersion=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: Connectionraw.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: Responseprotocol=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: Connectionraw.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: Responseprotocol=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 Responseprotocol=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: Requestmethod=GET, url=https://raw.github.com/square/okhttp/master/README.md
[15 ms] cacheConditionalHit: Responseprotocol=http/1.1, code=301, message=, url=https://raw.github.com/square/okhttp/master/README.md
[15 ms] connectionAcquired: Connectionraw.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: Responseprotocol=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: Responseprotocol=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 Responseprotocol=http/1.1, code=200, message=, url=https://raw.githubusercontent.com/square/okhttp/master/README.md network null
OkHttp

【讨论】:

添加.dns(object : Dns //... 使其从A/INTERCEPT: FALLBACK TO CACHE Unable to resolve host 变为A/INTERCEPT: FALLBACK TO CACHE Canceled,但响应/结果仍然没有回调。 在完全关闭移动数据/wifi 的情况下,我可以很好地接收缓存A/INTERCEPT: CACHE Responseprotocol=http/1.1, code=200, message=, url=https://api.com NETWORK null 据我观察,这与 OkHttp3 或 Retrofit2 的工作方式有关。我根本看不到任何与此相关的问题。 我在这里的目标是能够重现问题以修复它或了解问题所在。从您发布的代码示例来看,它不是可重现的形式。您能否尝试运行我提供的示例并对其进行调整以使其在您观察到的情况下失败。 但是当我尝试在线请求时缓存也不为空CACHE Responseprotocol=http/1.1, code=200, message=, url=https://api.com NETWORK Responseprotocol=h2, code=304, message=, url=https://api.com

以上是关于Retrofit2 和 OkHttp3 仅在发生错误时使用缓存,例如网络错误或达到配额限制的主要内容,如果未能解决你的问题,请参考以下文章

HTTP/2 与 OkHttp3 和 Retrofit2

http2 似乎不适用于 OkHttp3 和 retrofit2

Retrofit2 OkHttp3 响应正文空错误

OkHttp3 + retrofit2 封装

Android网络实战篇——OkHttp3(Retrofit2)五种缓存模式的实现

使用 Retrofit2 和 OkHttp3 在 API 获取请求后将 XML 布局转换为 Jetpack Compose