Skip to content

Commit 9b42afd

Browse files
authored
CacheFirstFetchPolicy: go to the network after a cache miss only (#351)
* Add refetchPolicyCacheFirstWithWriteToCacheAsync test * Use SQL cache * Go to the network if there's a cache miss only - not if there are other exceptions.
1 parent 3a045eb commit 9b42afd

7 files changed

Lines changed: 209 additions & 6 deletions

File tree

normalized-cache/src/commonMain/kotlin/com/apollographql/cache/normalized/FetchPolicyInterceptors.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ val DefaultFetchPolicyInterceptor = object : ApolloInterceptor {
4747
cacheResponse.newBuilder().isLast(request.onlyIfCached || cacheResponse.exception == null)
4848
.build(),
4949
)
50-
if (cacheResponse.exception == null) {
50+
if (cacheResponse.exception !is CacheMissException) {
5151
return@flow
5252
}
5353
}

tests/defer/src/commonTest/kotlin/test/DeferNormalizedCacheTest.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import com.apollographql.apollo.api.Error
77
import com.apollographql.apollo.api.Operation
88
import com.apollographql.apollo.exception.ApolloException
99
import com.apollographql.apollo.exception.ApolloGraphQLException
10-
import com.apollographql.apollo.exception.ApolloHttpException
1110
import com.apollographql.apollo.exception.ApolloNetworkException
1211
import com.apollographql.apollo.exception.CacheMissException
1312
import com.apollographql.apollo.network.NetworkTransport
@@ -454,13 +453,10 @@ class DeferNormalizedCacheTest {
454453
)
455454
assertResponseListEquals(networkExpected, networkActual)
456455

457-
mockServer.enqueueError(statusCode = 500)
458-
// Because of the error we fallback to the network (which also fails)
456+
// The error was stored in the cache and is surfaced as an exception
459457
val exception = apolloClient.query(WithFragmentSpreadsQuery()).execute().exception
460458
check(exception is ApolloGraphQLException)
461-
assertIs<ApolloHttpException>(exception.suppressedExceptions.first())
462459
assertEquals("GraphQL error: 'Cannot resolve isColor'", exception.message)
463-
mockServer.awaitRequest()
464460
}
465461

466462
@Test
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import com.apollographql.apollo.annotations.ApolloExperimental
2+
3+
plugins {
4+
alias(libs.plugins.kotlin.multiplatform)
5+
id("com.apollographql.apollo")
6+
}
7+
8+
kotlin {
9+
configureKmp(
10+
withJs = setOf(JsAndWasmEnvironment.Node),
11+
withWasm = emptySet(),
12+
withAndroid = false,
13+
withApple = AppleTargets.Host,
14+
)
15+
16+
sourceSets {
17+
getByName("commonMain") {
18+
dependencies {
19+
implementation(libs.apollo.runtime)
20+
implementation("com.apollographql.cache:normalized-cache")
21+
}
22+
}
23+
24+
getByName("commonTest") {
25+
dependencies {
26+
implementation("com.apollographql.cache:test-utils")
27+
implementation(libs.apollo.mockserver)
28+
implementation(libs.kotlin.test)
29+
implementation(libs.turbine)
30+
}
31+
}
32+
33+
getByName("jvmTest") {
34+
dependencies {
35+
implementation(libs.slf4j.nop)
36+
}
37+
}
38+
}
39+
}
40+
41+
apollo {
42+
service("service") {
43+
packageName.set("test")
44+
45+
@OptIn(ApolloExperimental::class)
46+
plugin("com.apollographql.cache:normalized-cache-apollo-compiler-plugin") {
47+
argument("com.apollographql.cache.packageName", packageName.get())
48+
}
49+
}
50+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
extend schema
2+
@link(
3+
url: "https://specs.apollo.dev/kotlin_labs/v0.3"
4+
)
5+
@link(
6+
url: "https://specs.apollo.dev/cache/v0.3",
7+
import: ["@typePolicy", "@cacheControl", "@cacheControlField"]
8+
)
9+
10+
extend type User @typePolicy(keyFields: "id")
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
query MeQuery {
2+
me {
3+
id
4+
firstName
5+
lastName
6+
}
7+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
type Query {
2+
me: User!
3+
}
4+
5+
type User {
6+
id: ID!
7+
firstName: String
8+
lastName: String
9+
email: String
10+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package test
2+
3+
import app.cash.turbine.test
4+
import app.cash.turbine.withTurbineTimeout
5+
import com.apollographql.apollo.ApolloClient
6+
import com.apollographql.apollo.api.Error
7+
import com.apollographql.apollo.exception.ApolloGraphQLException
8+
import com.apollographql.apollo.exception.CacheMissException
9+
import com.apollographql.cache.normalized.FetchPolicy.CacheFirst
10+
import com.apollographql.cache.normalized.api.CacheHeaders
11+
import com.apollographql.cache.normalized.api.NormalizedCache
12+
import com.apollographql.cache.normalized.api.NormalizedCacheFactory
13+
import com.apollographql.cache.normalized.api.Record
14+
import com.apollographql.cache.normalized.api.RecordMerger
15+
import com.apollographql.cache.normalized.isFromCache
16+
import com.apollographql.cache.normalized.memory.MemoryCacheFactory
17+
import com.apollographql.cache.normalized.refetchPolicy
18+
import com.apollographql.cache.normalized.testing.assertErrorsEquals
19+
import com.apollographql.cache.normalized.testing.runTest
20+
import com.apollographql.cache.normalized.watch
21+
import com.apollographql.mockserver.MockRequestBase
22+
import com.apollographql.mockserver.MockResponse
23+
import com.apollographql.mockserver.MockServer
24+
import com.apollographql.mockserver.MockServerHandler
25+
import kotlinx.coroutines.delay
26+
import okio.use
27+
import test.cache.Cache.cache
28+
import kotlin.random.Random
29+
import kotlin.test.Test
30+
import kotlin.test.assertFails
31+
import kotlin.test.assertFalse
32+
import kotlin.test.assertIs
33+
import kotlin.test.assertTrue
34+
import kotlin.time.Duration.Companion.milliseconds
35+
36+
class FetchPolicyTest {
37+
@Test
38+
fun writeToCacheAsyncWithRefetchPolicyCacheFirstAndServerErrors() = runTest {
39+
MockServer.Builder().handler(
40+
object : MockServerHandler {
41+
override fun handle(request: MockRequestBase): MockResponse {
42+
return MockResponse.Builder()
43+
.body(
44+
// language=JSON
45+
"""
46+
{
47+
"errors": [
48+
{
49+
"message": "Can't compute lastName",
50+
"path": [
51+
"me",
52+
"lastName"
53+
]
54+
}
55+
],
56+
"data": {
57+
"me": {
58+
"__typename": "User",
59+
"id": "1",
60+
"firstName": "${Random.nextInt()}",
61+
"lastName": null
62+
}
63+
}
64+
}
65+
""".trimIndent()
66+
)
67+
.build()
68+
}
69+
}
70+
).build().use { mockServer ->
71+
ApolloClient.Builder()
72+
.serverUrl(mockServer.url())
73+
.cache(AsyncCacheFactory(), writeToCacheAsynchronously = true)
74+
.build()
75+
.use { apolloClient ->
76+
apolloClient.query(MeQuery())
77+
.refetchPolicy(CacheFirst)
78+
.watch()
79+
.test {
80+
// 1. response from the cache (cache miss)
81+
val cacheResponse1 = awaitItem()
82+
assertTrue(cacheResponse1.isFromCache)
83+
assertIs<CacheMissException>(cacheResponse1.exception)
84+
85+
// 2. response from the network, with the error
86+
val networkResponse1 = awaitItem()
87+
assertFalse(networkResponse1.isFromCache)
88+
assertErrorsEquals(
89+
listOf(Error.Builder("Can't compute lastName").path(listOf("me", "lastName")).build()),
90+
networkResponse1.errors
91+
)
92+
93+
// 3. with writeToCacheAsynchronously, when the network response is written to the cache, the watcher gets notified with the cache response
94+
val cacheResponse2 = awaitItem()
95+
assertTrue(cacheResponse2.isFromCache)
96+
// GraphQL error is surfaced as an exception by default (serverErrorsAsException is true)
97+
assertIs<ApolloGraphQLException>(cacheResponse2.exception)
98+
99+
// That wasn't a cache miss: expect no more emissions
100+
withTurbineTimeout(200.milliseconds) {
101+
assertFails { awaitItem() }
102+
}
103+
104+
cancelAndIgnoreRemainingEvents()
105+
}
106+
}
107+
}
108+
}
109+
}
110+
111+
/**
112+
* A cache that simulates slow writes.
113+
* This removes flakiness by ensuring that when using `writeToCacheAsynchronously = true`, there is enough time to start observing the
114+
* cache, before writing happens.
115+
*/
116+
private fun AsyncCacheFactory(): NormalizedCacheFactory = object : NormalizedCacheFactory() {
117+
override fun create(): NormalizedCache {
118+
val wrapped = MemoryCacheFactory().create()
119+
return object : NormalizedCache by wrapped {
120+
override suspend fun merge(
121+
records: Collection<Record>,
122+
cacheHeaders: CacheHeaders,
123+
recordMerger: RecordMerger,
124+
): Set<String> {
125+
delay(100.milliseconds)
126+
return wrapped.merge(records, cacheHeaders, recordMerger)
127+
}
128+
}
129+
}
130+
}

0 commit comments

Comments
 (0)