Skip to content

Commit 02c85f6

Browse files
committed
fix(cache): respect max-age before ETag revalidation
ETagCacheStrategy was forcing a network trip on every cold start, causing token icons to flash. Parse Cache-Control max-age from cached response headers and only revalidate once the entry is stale. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 7e1c231 commit 02c85f6

2 files changed

Lines changed: 166 additions & 29 deletions

File tree

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/cache/ETagRevalidation.kt

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,17 @@ fun ImageRequest.Builder.etagRevalidation() = apply {
3636
}
3737

3838
/**
39-
* A [CacheStrategy] that performs conditional revalidation using `ETag` / `Last-Modified`
40-
* headers stored in Coil's disk-cache metadata.
39+
* A [CacheStrategy] that respects `Cache-Control: max-age` and performs conditional
40+
* revalidation using `ETag` / `Last-Modified` headers stored in Coil's disk-cache metadata.
4141
*
4242
* **Read behaviour:**
4343
* - If [ETagRevalidationKey] is **not** set on the request, the cached response is returned
4444
* immediately (same as [CacheStrategy.DEFAULT]).
45-
* - If the key **is** set, cached `etag` and `last-modified` headers are copied into
46-
* `If-None-Match` / `If-Modified-Since` on the outgoing [NetworkRequest], forcing a
47-
* network round-trip. The server can then respond with `304` to avoid re-downloading the
48-
* image body.
45+
* - If the key **is** set but the cached entry is still fresh (within `max-age`), the cached
46+
* response is returned without a network trip.
47+
* - If the entry is stale, cached `etag` and `last-modified` headers are copied into
48+
* `If-None-Match` / `If-Modified-Since` on the outgoing [NetworkRequest]. The server can
49+
* then respond with `304` to avoid re-downloading the image body.
4950
*
5051
* **Write behaviour:** delegates entirely to [CacheStrategy.DEFAULT], which merges headers on
5152
* `304` responses and writes new bodies on `2xx` responses.
@@ -62,6 +63,10 @@ class ETagCacheStrategy : CacheStrategy {
6263
return CacheStrategy.ReadResult(cacheResponse)
6364
}
6465

66+
if (!isStale(cacheResponse)) {
67+
return CacheStrategy.ReadResult(cacheResponse)
68+
}
69+
6570
val cachedHeaders = cacheResponse.headers
6671
val etag = cachedHeaders["etag"]
6772
val lastModified = cachedHeaders["last-modified"]
@@ -91,6 +96,27 @@ class ETagCacheStrategy : CacheStrategy {
9196
): CacheStrategy.WriteResult {
9297
return CacheStrategy.DEFAULT.write(cacheResponse, networkRequest, networkResponse, options)
9398
}
99+
100+
companion object {
101+
private val MAX_AGE_REGEX = Regex("""max-age\s*=\s*(\d+)""")
102+
103+
/**
104+
* Returns `true` if the cached response has exceeded its `max-age`, or if
105+
* `responseMillis` is missing (pre-existing cache entries).
106+
*/
107+
internal fun isStale(cacheResponse: NetworkResponse): Boolean {
108+
val responseMillis = cacheResponse.responseMillis
109+
if (responseMillis <= 0L) return true
110+
111+
val cacheControl = cacheResponse.headers["cache-control"] ?: return true
112+
val maxAgeSeconds = MAX_AGE_REGEX.find(cacheControl)
113+
?.groupValues?.get(1)?.toLongOrNull()
114+
?: return true
115+
116+
val ageMillis = System.currentTimeMillis() - responseMillis
117+
return ageMillis > maxAgeSeconds * 1000
118+
}
119+
}
94120
}
95121

96122
/**

apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/cache/ETagCacheStrategyTest.kt

Lines changed: 134 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import org.robolectric.RuntimeEnvironment
1313
import org.robolectric.annotation.Config
1414
import kotlin.test.Test
1515
import kotlin.test.assertEquals
16+
import kotlin.test.assertFalse
1617
import kotlin.test.assertNotNull
1718
import kotlin.test.assertNull
19+
import kotlin.test.assertTrue
1820

1921
@OptIn(ExperimentalCoilApi::class)
2022
@RunWith(RobolectricTestRunner::class)
@@ -30,52 +32,121 @@ class ETagCacheStrategyTest {
3032
return Options(context = RuntimeEnvironment.getApplication(), extras = extras)
3133
}
3234

33-
private val baseCacheResponse = NetworkResponse(
34-
code = 200,
35-
headers = NetworkHeaders.EMPTY,
36-
)
35+
private fun freshCacheResponse(
36+
maxAgeSeconds: Long = 3600,
37+
etag: String? = null,
38+
lastModified: String? = null,
39+
): NetworkResponse {
40+
val headers = NetworkHeaders.Builder()
41+
.set("cache-control", "public, max-age=$maxAgeSeconds")
42+
if (etag != null) headers.set("etag", etag)
43+
if (lastModified != null) headers.set("last-modified", lastModified)
44+
return NetworkResponse(
45+
code = 200,
46+
responseMillis = System.currentTimeMillis() - 60_000, // 1 minute ago
47+
headers = headers.build(),
48+
)
49+
}
50+
51+
private fun staleCacheResponse(
52+
maxAgeSeconds: Long = 3600,
53+
etag: String? = null,
54+
lastModified: String? = null,
55+
): NetworkResponse {
56+
val headers = NetworkHeaders.Builder()
57+
.set("cache-control", "public, max-age=$maxAgeSeconds")
58+
if (etag != null) headers.set("etag", etag)
59+
if (lastModified != null) headers.set("last-modified", lastModified)
60+
return NetworkResponse(
61+
code = 200,
62+
responseMillis = System.currentTimeMillis() - (maxAgeSeconds * 1000 + 60_000),
63+
headers = headers.build(),
64+
)
65+
}
3766

3867
private val baseNetworkRequest = NetworkRequest(url = "https://example.com/icon.png")
3968

69+
// region key disabled
70+
4071
@Test
4172
fun `without key returns cache response`() = runTest {
42-
val result = strategy.read(baseCacheResponse, baseNetworkRequest, options(false))
73+
val result = strategy.read(staleCacheResponse(), baseNetworkRequest, options(false))
4374
assertNotNull(result.response)
4475
assertNull(result.request)
4576
}
4677

78+
// endregion
79+
80+
// region freshness
81+
4782
@Test
48-
fun `with key and cached etag adds If-None-Match`() = runTest {
49-
val cached = baseCacheResponse.copy(
83+
fun `with key and fresh cache returns cache response`() = runTest {
84+
val cached = freshCacheResponse(etag = "\"abc123\"")
85+
val result = strategy.read(cached, baseNetworkRequest, options(true))
86+
assertNotNull(result.response)
87+
assertNull(result.request)
88+
}
89+
90+
@Test
91+
fun `with key and stale cache revalidates`() = runTest {
92+
val cached = staleCacheResponse(etag = "\"abc123\"")
93+
val result = strategy.read(cached, baseNetworkRequest, options(true))
94+
assertNotNull(result.request)
95+
assertNull(result.response)
96+
}
97+
98+
@Test
99+
fun `with key and no cache-control header treats as stale`() = runTest {
100+
val cached = NetworkResponse(
101+
code = 200,
102+
responseMillis = System.currentTimeMillis(),
50103
headers = NetworkHeaders.Builder()
51104
.set("etag", "\"abc123\"")
52-
.build()
105+
.build(),
53106
)
54107
val result = strategy.read(cached, baseNetworkRequest, options(true))
55108
assertNotNull(result.request)
56-
assertNull(result.response)
57-
assertEquals("\"abc123\"", result.request!!.headers["if-none-match"])
58109
}
59110

60111
@Test
61-
fun `with key and cached last-modified adds If-Modified-Since`() = runTest {
62-
val cached = baseCacheResponse.copy(
112+
fun `with key and zero responseMillis treats as stale`() = runTest {
113+
val cached = NetworkResponse(
114+
code = 200,
115+
responseMillis = 0L,
63116
headers = NetworkHeaders.Builder()
64-
.set("last-modified", "Wed, 21 Oct 2015 07:28:00 GMT")
65-
.build()
117+
.set("cache-control", "public, max-age=3600")
118+
.set("etag", "\"abc123\"")
119+
.build(),
66120
)
67121
val result = strategy.read(cached, baseNetworkRequest, options(true))
68122
assertNotNull(result.request)
123+
}
124+
125+
// endregion
126+
127+
// region conditional headers
128+
129+
@Test
130+
fun `stale cache with etag adds If-None-Match`() = runTest {
131+
val cached = staleCacheResponse(etag = "\"abc123\"")
132+
val result = strategy.read(cached, baseNetworkRequest, options(true))
133+
assertNotNull(result.request)
134+
assertEquals("\"abc123\"", result.request!!.headers["if-none-match"])
135+
}
136+
137+
@Test
138+
fun `stale cache with last-modified adds If-Modified-Since`() = runTest {
139+
val cached = staleCacheResponse(lastModified = "Wed, 21 Oct 2015 07:28:00 GMT")
140+
val result = strategy.read(cached, baseNetworkRequest, options(true))
141+
assertNotNull(result.request)
69142
assertEquals("Wed, 21 Oct 2015 07:28:00 GMT", result.request!!.headers["if-modified-since"])
70143
}
71144

72145
@Test
73-
fun `with key and both headers adds both conditional headers`() = runTest {
74-
val cached = baseCacheResponse.copy(
75-
headers = NetworkHeaders.Builder()
76-
.set("etag", "\"abc123\"")
77-
.set("last-modified", "Wed, 21 Oct 2015 07:28:00 GMT")
78-
.build()
146+
fun `stale cache with both headers adds both conditional headers`() = runTest {
147+
val cached = staleCacheResponse(
148+
etag = "\"abc123\"",
149+
lastModified = "Wed, 21 Oct 2015 07:28:00 GMT",
79150
)
80151
val result = strategy.read(cached, baseNetworkRequest, options(true))
81152
assertNotNull(result.request)
@@ -84,11 +155,51 @@ class ETagCacheStrategyTest {
84155
}
85156

86157
@Test
87-
fun `with key and no cached headers sends plain request`() = runTest {
88-
val result = strategy.read(baseCacheResponse, baseNetworkRequest, options(true))
158+
fun `stale cache with no etag or last-modified sends plain request`() = runTest {
159+
val cached = staleCacheResponse()
160+
val result = strategy.read(cached, baseNetworkRequest, options(true))
89161
assertNotNull(result.request)
90-
assertNull(result.response)
91162
assertNull(result.request!!.headers["if-none-match"])
92163
assertNull(result.request!!.headers["if-modified-since"])
93164
}
165+
166+
// endregion
167+
168+
// region isStale
169+
170+
@Test
171+
fun `isStale returns false when within max-age`() {
172+
val response = freshCacheResponse(maxAgeSeconds = 3600)
173+
assertFalse(ETagCacheStrategy.isStale(response))
174+
}
175+
176+
@Test
177+
fun `isStale returns true when past max-age`() {
178+
val response = staleCacheResponse(maxAgeSeconds = 3600)
179+
assertTrue(ETagCacheStrategy.isStale(response))
180+
}
181+
182+
@Test
183+
fun `isStale returns true when no cache-control`() {
184+
val response = NetworkResponse(
185+
code = 200,
186+
responseMillis = System.currentTimeMillis(),
187+
headers = NetworkHeaders.EMPTY,
188+
)
189+
assertTrue(ETagCacheStrategy.isStale(response))
190+
}
191+
192+
@Test
193+
fun `isStale returns true when responseMillis is zero`() {
194+
val response = NetworkResponse(
195+
code = 200,
196+
responseMillis = 0L,
197+
headers = NetworkHeaders.Builder()
198+
.set("cache-control", "public, max-age=3600")
199+
.build(),
200+
)
201+
assertTrue(ETagCacheStrategy.isStale(response))
202+
}
203+
204+
// endregion
94205
}

0 commit comments

Comments
 (0)