Skip to content

Commit 9def5f3

Browse files
authored
HTTP cache: do not remove cached entries on transport errors (#6314)
* HTTP cache: do not remove cached entries on transport errors See #6313 * only filter out ApolloNetworkErrors
1 parent 9eadd40 commit 9def5f3

File tree

3 files changed

+158
-7
lines changed

3 files changed

+158
-7
lines changed

libraries/apollo-http-cache/src/main/kotlin/com/apollographql/apollo/cache/http/internal/HttpCacheApolloInterceptor.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.apollographql.apollo.api.Subscription
99
import com.apollographql.apollo.cache.http.CachingHttpInterceptor
1010
import com.apollographql.apollo.cache.http.HttpFetchPolicy
1111
import com.apollographql.apollo.cache.http.HttpFetchPolicyContext
12+
import com.apollographql.apollo.exception.ApolloNetworkException
1213
import com.apollographql.apollo.interceptor.ApolloInterceptor
1314
import com.apollographql.apollo.interceptor.ApolloInterceptorChain
1415
import kotlinx.coroutines.flow.Flow
@@ -48,9 +49,17 @@ internal class HttpCacheApolloInterceptor(
4849
.run {
4950
if (request.operation is Query<*>) {
5051
onEach { response ->
51-
// Revert caching of responses with errors
5252
val cacheKey = synchronized(apolloRequestToCacheKey) { apolloRequestToCacheKey[request.requestUuid.toString()] }
53-
if (response.hasErrors() || response.exception != null) {
53+
/**
54+
* We revert caching if:
55+
* - there are GraphQL errors
56+
* - or if data was received (not an ApolloNetworkException) but could not be parsed to a valid GraphQL response
57+
*
58+
* This is fragile as it might remove useful data.
59+
* A better version of this should probably delegate that decision to the server using HTTP codes.
60+
* See https://github.com/apollographql/apollo-kotlin/issues/6313#issuecomment-2531538619
61+
*/
62+
if (response.hasErrors() || response.exception != null && response.exception !is ApolloNetworkException) {
5463
try {
5564
cacheKey?.let { cachingHttpInterceptor.cache.remove(it) }
5665
} catch (_: IOException) {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import com.apollographql.apollo.ApolloClient
2+
import com.apollographql.apollo.api.http.HttpRequest
3+
import com.apollographql.apollo.api.http.HttpResponse
4+
import com.apollographql.apollo.cache.http.httpCache
5+
import com.apollographql.apollo.exception.ApolloNetworkException
6+
import com.apollographql.apollo.network.http.HttpEngine
7+
import com.apollographql.apollo.network.http.HttpNetworkTransport
8+
import httpcache.GetRandomQuery
9+
import kotlinx.coroutines.runBlocking
10+
import okio.Buffer
11+
import okio.IOException
12+
import okio.Source
13+
import okio.Timeout
14+
import okio.buffer
15+
import java.io.File
16+
import kotlin.test.Test
17+
import kotlin.test.assertEquals
18+
import kotlin.test.assertIs
19+
20+
class FaultySource: Source {
21+
22+
private val data = Buffer().writeUtf8("{ \"data\":")
23+
24+
override fun close() {
25+
26+
}
27+
28+
override fun read(sink: Buffer, byteCount: Long): Long {
29+
val remaining = data.size.coerceAtMost(byteCount)
30+
if (remaining == 0L) {
31+
throw IOException("failed to read")
32+
}
33+
data.read(sink, remaining)
34+
35+
return remaining
36+
}
37+
38+
override fun timeout(): Timeout {
39+
return Timeout.NONE
40+
}
41+
}
42+
43+
class FaultyEngine: HttpEngine {
44+
val requests = mutableListOf<HttpRequest>()
45+
46+
override suspend fun execute(request: HttpRequest): HttpResponse {
47+
requests.add(request)
48+
49+
return HttpResponse.Builder(200)
50+
.body(FaultySource().buffer())
51+
.addHeader("content-length", "30")
52+
.build()
53+
}
54+
}
55+
56+
class FaultyEngineTest {
57+
58+
@Test
59+
fun httpErrorsAreNotCached() = runBlocking {
60+
val engine = FaultyEngine()
61+
62+
val dir = File("build/httpCache")
63+
dir.deleteRecursively()
64+
65+
val apolloClient = ApolloClient.Builder().apply {
66+
httpCache(dir, Long.MAX_VALUE)
67+
networkTransport(
68+
HttpNetworkTransport.Builder()
69+
.serverUrl("unused")
70+
.interceptors(httpInterceptors)
71+
.httpEngine(engine)
72+
.build()
73+
)
74+
httpInterceptors(emptyList())
75+
}.build()
76+
77+
apolloClient.query(GetRandomQuery()).execute().apply {
78+
assertIs<ApolloNetworkException>(exception)
79+
}
80+
apolloClient.query(GetRandomQuery()).execute().apply {
81+
assertIs<ApolloNetworkException>(exception)
82+
}
83+
84+
assertEquals(2, engine.requests.size)
85+
}
86+
}

tests/http-cache/src/test/kotlin/HttpCacheTest.kt

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.apollographql.apollo.cache.http.httpFetchPolicy
1111
import com.apollographql.apollo.cache.http.isFromHttpCache
1212
import com.apollographql.apollo.exception.ApolloNetworkException
1313
import com.apollographql.apollo.exception.HttpCacheMissException
14+
import com.apollographql.apollo.exception.JsonEncodingException
1415
import com.apollographql.mockserver.MockServer
1516
import com.apollographql.mockserver.awaitRequest
1617
import com.apollographql.mockserver.enqueueError
@@ -21,6 +22,7 @@ import httpcache.GetRandom2Query
2122
import httpcache.GetRandomQuery
2223
import httpcache.RandomSubscription
2324
import httpcache.SetRandomMutation
25+
import junit.framework.TestCase.assertFalse
2426
import kotlinx.coroutines.delay
2527
import kotlinx.coroutines.runBlocking
2628
import okhttp3.Interceptor
@@ -31,6 +33,7 @@ import kotlin.test.Test
3133
import kotlin.test.assertEquals
3234
import kotlin.test.assertIs
3335
import kotlin.test.assertNotNull
36+
import kotlin.test.assertTrue
3437

3538
class HttpCacheTest {
3639
lateinit var mockServer: MockServer
@@ -215,18 +218,71 @@ class HttpCacheTest {
215218
}
216219
}
217220

221+
/**
222+
* Whether an incomplete Json is an IO error is still an open question:
223+
* - [ResponseParser] considers yes (and throws an ApolloNetworkException)
224+
* - [ProxySource] considers no (and doesn't abort)
225+
*
226+
* This isn't great and will need to be revisited if that ever becomes a bigger problem
227+
*/
218228
@Test
219-
fun incompleteJsonIsNotCached() = runTest(before = { before() }, after = { tearDown() }) {
229+
fun incompleteJsonTriggersNetworkException() = runTest(before = { before() }, after = { tearDown() }) {
220230
mockServer.enqueueString("""{"data":""")
231+
apolloClient.query(GetRandomQuery()).execute().apply {
232+
assertIs<ApolloNetworkException>(exception)
233+
}
234+
235+
apolloClient.query(GetRandomQuery()).httpFetchPolicy(HttpFetchPolicy.CacheOnly).execute().apply {
236+
/**
237+
* Because there's a disagreement between ProxySource and HttpCacheApolloInterceptor, the value is stored in the
238+
* HTTP cache and is replayed here
239+
*/
240+
assertIs<ApolloNetworkException>(exception)
241+
}
242+
}
243+
244+
@Test
245+
fun malformedJsonIsNotCached() = runTest(before = { before() }, after = { tearDown() }) {
246+
mockServer.enqueueString("""{"data":}""")
221247
apolloClient.query(GetRandomQuery()).execute().exception.apply {
222-
assertIs<ApolloNetworkException>(this)
248+
assertIs<JsonEncodingException>(this)
223249
}
224250
// Should not have been cached
225-
assertIs<HttpCacheMissException>(
226-
apolloClient.query(GetRandomQuery()).httpFetchPolicy(HttpFetchPolicy.CacheOnly).execute().exception
227-
)
251+
apolloClient.query(GetRandomQuery()).httpFetchPolicy(HttpFetchPolicy.CacheOnly).execute().apply {
252+
assertIs<HttpCacheMissException>(exception)
253+
}
254+
}
255+
256+
@Test
257+
fun ioErrorDoesNotRemoveData() = runTest(before = { before() }, after = { tearDown() }) {
258+
mockServer.enqueueString("""
259+
{
260+
"data": {
261+
"random": "42"
262+
}
263+
}
264+
""".trimIndent())
265+
266+
// Warm the cache
267+
apolloClient.query(GetRandomQuery()).execute().apply {
268+
assertEquals(42, data?.random)
269+
assertFalse(isFromHttpCache)
270+
}
271+
272+
// Go offline
273+
mockServer.close()
274+
apolloClient.query(GetRandomQuery()).httpFetchPolicy(HttpFetchPolicy.NetworkOnly).execute().apply {
275+
assertIs<ApolloNetworkException>(exception)
276+
}
277+
278+
// The data is still there
279+
apolloClient.query(GetRandomQuery()).execute().apply {
280+
assertEquals(42, data?.random)
281+
assertTrue(isFromHttpCache)
282+
}
228283
}
229284

285+
230286
@Test
231287
fun responseWithGraphQLErrorIsNotCached() = runTest(before = { before() }, after = { tearDown() }) {
232288
mockServer.enqueueString("""

0 commit comments

Comments
 (0)