Skip to content

Commit a77241a

Browse files
authored
KTOR-4894 Fixed HttpCache client plugin ignoring Request Cache-Control directives (#3167)
1 parent 82eaa1e commit a77241a

File tree

5 files changed

+177
-11
lines changed

5 files changed

+177
-11
lines changed

buildSrc/src/main/kotlin/test/server/tests/Cache.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ internal fun Application.cacheTestServer() {
4545
}
4646

4747
/**
48-
* Return same etag for first 2 responses.
48+
* Return same etag for the first 2 responses.
4949
*/
5050
get("/etag") {
5151
val maxAge = call.request.queryParameters["max-age"]?.toIntOrNull()

ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/cache/HttpCache.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public class HttpCache private constructor(
9191
return@intercept
9292
}
9393
val cachedCall = cache.produceResponse().call
94-
val validateStatus = cache.shouldValidate(context)
94+
val validateStatus = shouldValidate(cache.expires, cache.response.headers, context.headers)
9595

9696
if (validateStatus == ValidateStatus.ShouldNotValidate) {
9797
proceedWithCache(scope, cachedCall)
@@ -185,10 +185,11 @@ public class HttpCache private constructor(
185185
private suspend fun cacheResponse(response: HttpResponse): HttpResponse {
186186
val request = response.call.request
187187
val responseCacheControl: List<HeaderValue> = response.cacheControl()
188+
val requestCacheControl: List<HeaderValue> = request.cacheControl()
188189

189190
val storage = if (CacheControl.PRIVATE in responseCacheControl) privateStorage else publicStorage
190191

191-
if (CacheControl.NO_STORE in responseCacheControl) {
192+
if (CacheControl.NO_STORE in responseCacheControl || CacheControl.NO_STORE in requestCacheControl) {
192193
return response
193194
}
194195

ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/cache/HttpCacheEntry.kt

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
package io.ktor.client.plugins.cache
66

77
import io.ktor.client.call.*
8-
import io.ktor.client.request.*
98
import io.ktor.client.statement.*
109
import io.ktor.http.*
1110
import io.ktor.util.*
1211
import io.ktor.util.date.*
1312
import io.ktor.utils.io.core.*
13+
import kotlin.collections.*
1414

1515
@OptIn(InternalAPI::class)
1616
internal suspend fun HttpCacheEntry(response: HttpResponse): HttpCacheEntry {
@@ -90,15 +90,27 @@ internal fun HttpResponse.cacheExpires(fallback: () -> GMTDate = { GMTDate() }):
9090
} ?: fallback()
9191
}
9292

93-
internal fun HttpCacheEntry.shouldValidate(request: HttpRequestBuilder): ValidateStatus {
94-
val cacheControl = parseHeaderValue(responseHeaders[HttpHeaders.CacheControl])
95-
val validMillis = expires.timestamp - getTimeMillis()
93+
internal fun shouldValidate(
94+
cacheExpires: GMTDate,
95+
responseHeaders: Headers,
96+
requestHeaders: HeadersBuilder
97+
): ValidateStatus {
98+
val responseCacheControl = parseHeaderValue(responseHeaders[HttpHeaders.CacheControl])
99+
val requestCacheControl = parseHeaderValue(requestHeaders[HttpHeaders.CacheControl])
96100

97-
if (CacheControl.NO_CACHE in cacheControl) return ValidateStatus.ShouldValidate
101+
if (CacheControl.NO_CACHE in requestCacheControl) return ValidateStatus.ShouldValidate
102+
103+
val requestMaxAge = requestCacheControl.firstOrNull { it.value.startsWith("max-age=") }
104+
?.value?.split("=")
105+
?.get(1)?.let { it.toIntOrNull() ?: 0 }
106+
if (requestMaxAge == 0) return ValidateStatus.ShouldValidate
107+
108+
val validMillis = cacheExpires.timestamp - getTimeMillis()
109+
110+
if (CacheControl.NO_CACHE in responseCacheControl) return ValidateStatus.ShouldValidate
98111
if (validMillis > 0) return ValidateStatus.ShouldNotValidate
99-
if (CacheControl.MUST_REVALIDATE in cacheControl) return ValidateStatus.ShouldValidate
112+
if (CacheControl.MUST_REVALIDATE in responseCacheControl) return ValidateStatus.ShouldValidate
100113

101-
val requestCacheControl = parseHeaderValue(request.headers[HttpHeaders.CacheControl])
102114
val maxStale = requestCacheControl.firstOrNull { it.value.startsWith("max-stale=") }
103115
?.value?.substring("max-stale=".length)
104116
?.toIntOrNull() ?: 0
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2014-2022 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package io.ktor.client.plugins.cache.tests
6+
7+
import io.ktor.client.plugins.cache.*
8+
import io.ktor.http.*
9+
import io.ktor.util.date.*
10+
import kotlin.test.*
11+
12+
class ShouldValidateTest {
13+
14+
@Test
15+
fun testNoCacheInRequestReturnsShouldValidate() {
16+
val result = shouldValidate(
17+
GMTDate(),
18+
Headers.Empty,
19+
HeadersBuilder().apply { append(HttpHeaders.CacheControl, "no-cache") }
20+
)
21+
assertEquals(ValidateStatus.ShouldValidate, result)
22+
}
23+
24+
@Test
25+
fun testNoCacheInResponseReturnsShouldValidate() {
26+
val result = shouldValidate(
27+
GMTDate(),
28+
headersOf(HttpHeaders.CacheControl, "no-cache"),
29+
HeadersBuilder()
30+
)
31+
assertEquals(ValidateStatus.ShouldValidate, result)
32+
}
33+
34+
@Test
35+
fun testMaxAge0InRequestReturnsShouldValidate() {
36+
val result = shouldValidate(
37+
GMTDate(),
38+
headersOf(HttpHeaders.CacheControl, "max-age=0"),
39+
HeadersBuilder()
40+
)
41+
assertEquals(ValidateStatus.ShouldValidate, result)
42+
}
43+
44+
@Test
45+
fun testExpiresInPastAndMustRevalidateReturnsShouldValidate() {
46+
val result = shouldValidate(
47+
GMTDate(getTimeMillis() - 1),
48+
headersOf(HttpHeaders.CacheControl, "must-revalidate"),
49+
HeadersBuilder()
50+
)
51+
assertEquals(ValidateStatus.ShouldValidate, result)
52+
}
53+
54+
@Test
55+
fun testExpiresInPastAndMaxStaleInFutureReturnsShouldWarn() {
56+
val result = shouldValidate(
57+
GMTDate(getTimeMillis() - 1),
58+
Headers.Empty,
59+
HeadersBuilder().apply { append(HttpHeaders.CacheControl, "max-stale=10") }
60+
)
61+
assertEquals(ValidateStatus.ShouldWarn, result)
62+
}
63+
64+
@Test
65+
fun testExpiresInPastReturnsShouldValidate() {
66+
val result = shouldValidate(
67+
GMTDate(getTimeMillis() - 1),
68+
Headers.Empty,
69+
HeadersBuilder()
70+
)
71+
assertEquals(ValidateStatus.ShouldValidate, result)
72+
}
73+
}

ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/plugins/CacheTest.kt

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,87 @@ class CacheTest : ClientLoader() {
384384
}
385385
}
386386

387+
@Test
388+
fun testNoStoreRequest() = clientTests(listOf("Js")) {
389+
config {
390+
install(HttpCache) {
391+
storage = this
392+
}
393+
}
394+
395+
test { client ->
396+
val url = Url("$TEST_SERVER/cache/etag")
397+
398+
val first = client.get(url) {
399+
header(HttpHeaders.CacheControl, "no-store")
400+
}.body<String>()
401+
assertEquals(0, storage!!.publicStorage.findByUrl(url).size)
402+
403+
val second = client.get(url).body<String>()
404+
assertEquals(1, storage!!.publicStorage.findByUrl(url).size)
405+
406+
assertNotEquals(first, second)
407+
}
408+
}
409+
410+
@Test
411+
fun testNoCacheRequest() = clientTests(listOf("Js")) {
412+
config {
413+
install(HttpCache) {
414+
storage = this
415+
}
416+
}
417+
418+
test { client ->
419+
var requestsCount = 0
420+
client.sendPipeline.intercept(HttpSendPipeline.Engine) {
421+
requestsCount++
422+
}
423+
424+
val url = Url("$TEST_SERVER/cache/etag?max-age=30")
425+
426+
val first = client.get(url).body<String>()
427+
assertEquals(1, storage!!.publicStorage.findByUrl(url).size)
428+
429+
val second = client.get(url) {
430+
header(HttpHeaders.CacheControl, "no-cache")
431+
}.body<String>()
432+
assertEquals(1, storage!!.publicStorage.findByUrl(url).size)
433+
434+
assertEquals(2, requestsCount)
435+
assertEquals(first, second)
436+
}
437+
}
438+
439+
@Test
440+
fun testRequestWithMaxAge0() = clientTests(listOf("Js")) {
441+
config {
442+
install(HttpCache) {
443+
storage = this
444+
}
445+
}
446+
447+
test { client ->
448+
var requestsCount = 0
449+
client.sendPipeline.intercept(HttpSendPipeline.Engine) {
450+
requestsCount++
451+
}
452+
453+
val url = Url("$TEST_SERVER/cache/etag?max-age=30")
454+
455+
val first = client.get(url).body<String>()
456+
assertEquals(1, storage!!.publicStorage.findByUrl(url).size)
457+
458+
val second = client.get(url) {
459+
header(HttpHeaders.CacheControl, "max-age=0")
460+
}.body<String>()
461+
assertEquals(1, storage!!.publicStorage.findByUrl(url).size)
462+
463+
assertEquals(2, requestsCount)
464+
assertEquals(first, second)
465+
}
466+
}
467+
387468
@Test
388469
fun testExpires() = clientTests {
389470
config {
@@ -496,7 +577,6 @@ class CacheTest : ClientLoader() {
496577
}
497578
}
498579

499-
@OptIn(InternalAPI::class)
500580
@Test
501581
fun testWithLogging() = clientTests {
502582
config {

0 commit comments

Comments
 (0)