Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ktor-client/ktor-client-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ kotlin {
implementation(projects.ktorTestDispatcher)
implementation(projects.ktorClientMock)
implementation(projects.ktorServerTestHost)
implementation(projects.ktorClientContentNegotiation)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -393,8 +393,8 @@ internal fun mergedHeadersLookup(
}

else -> {
val value = content.headers.getAll(header) ?: allHeadersExtractor(header) ?: emptyList()
value.joinToString(";")
val value = content.headers.getAll(header) ?: allHeadersExtractor(header)
value.joinHeaderValues()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,17 @@ public class HttpCacheEntry internal constructor(
}
}

// RFC 7230 §3.2.2: multiple values for the same header field are equivalent to a comma-separated list.
internal fun List<String>?.joinHeaderValues(): String = this?.joinToString(",") ?: ""

internal fun HttpResponse.varyKeys(): Map<String, String> {
val validationKeys = vary() ?: return emptyMap()

val result = mutableMapOf<String, String>()
val requestHeaders = call.request.headers

for (key in validationKeys) {
result[key.lowercase()] = requestHeaders.getAll(key)?.joinToString(",") ?: ""
result[key.lowercase()] = requestHeaders.getAll(key).joinHeaderValues()
}

return result
Expand Down
119 changes: 119 additions & 0 deletions ktor-client/ktor-client-core/common/test/HttpCacheTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.mock.*
import io.ktor.client.plugins.api.*
import io.ktor.client.plugins.cache.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.serialization.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.testing.*
import io.ktor.util.*
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
import io.ktor.utils.io.charsets.*
import kotlinx.coroutines.*
import kotlinx.coroutines.test.TestResult
import kotlinx.coroutines.test.runTest
Expand Down Expand Up @@ -162,6 +169,118 @@ class HttpCacheTest {
assertEquals(firstResponse, secondResponse)
}

// A no-op converter whose only effect is to register application/json as an Accept type.
// ContentNegotiation unconditionally appends Accept for every registered codec via
// `request.accept(it.contentTypeToSend)`, which creates a second header value when the
// caller also sets Accept explicitly. This is sufficient to trigger the varyKey mismatch.
private val noOpJsonConverter = object : ContentConverter {
override suspend fun serialize(
contentType: ContentType,
charset: Charset,
typeInfo: TypeInfo,
value: Any?
): OutgoingContent? = null

override suspend fun deserialize(
charset: Charset,
typeInfo: TypeInfo,
content: ByteReadChannel
): Any? = null
}

private fun MockRequestHandleScope.cacheableJsonResponse() = respond(
content = """{"id":1}""",
headers = headersOf(
HttpHeaders.ContentType to listOf(ContentType.Application.Json.toString()),
HttpHeaders.CacheControl to listOf("max-age=60"),
HttpHeaders.Vary to listOf("Accept"),
HttpHeaders.ETag to listOf("\"abc123\""),
)
)

/**
* Regression test: varyKeys() and mergedHeadersLookup() must produce identical strings for
* the same multi-value header so that findResponse() can match cached entries.
*
* Before the fix, varyKeys() joined with "," while mergedHeadersLookup() joined with ";",
* so stored "a,b" never equalled looked-up "a;b" and every request was a cache miss.
* Both now delegate to joinHeaderValues() (RFC 7230 §3.2.2 comma separator).
*/
@Test
fun varyKeysSeparatorMatchesMergedHeadersLookupSeparator() {
val requestHeaders = Headers.build {
append(HttpHeaders.Accept, "application/vnd.github+json") // set by caller
append(HttpHeaders.Accept, "application/json") // appended by ContentNegotiation
}

// How HttpCacheEntry.varyKeys() stores the value (joinHeaderValues → comma):
val stored = requestHeaders.getAll(HttpHeaders.Accept).joinHeaderValues()
// → "application/vnd.github+json,application/json"

// How mergedHeadersLookup() now computes the lookup value (joinHeaderValues → comma):
val lookup = requestHeaders.getAll(HttpHeaders.Accept).joinHeaderValues()
// → "application/vnd.github+json,application/json"

assertEquals(stored, lookup)
}
Comment on lines +210 to +225

@coderabbitai coderabbitai Bot May 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Regression assertion is currently tautological and can miss separator regressions.

stored and lookup are both built via the same joinHeaderValues() call, so this test can still pass if both paths regress to the same wrong separator. Assert the exact expected RFC-compliant value to make this test fail on separator drift.

Suggested patch
     fun varyKeysSeparatorMatchesMergedHeadersLookupSeparator() {
         val requestHeaders = Headers.build {
             append(HttpHeaders.Accept, "application/vnd.github+json") // set by caller
             append(HttpHeaders.Accept, "application/json")            // appended by ContentNegotiation
         }

         // How HttpCacheEntry.varyKeys() stores the value (joinHeaderValues → comma):
         val stored = requestHeaders.getAll(HttpHeaders.Accept).joinHeaderValues()
-        //  → "application/vnd.github+json,application/json"
+        val expected = "application/vnd.github+json,application/json"

         // How mergedHeadersLookup() now computes the lookup value (joinHeaderValues → comma):
         val lookup = requestHeaders.getAll(HttpHeaders.Accept).joinHeaderValues()
-        //  → "application/vnd.github+json,application/json"
-
+        
+        assertEquals(expected, stored)
+        assertEquals(expected, lookup)
         assertEquals(stored, lookup)
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ktor-client/ktor-client-core/common/test/HttpCacheTest.kt` around lines 210 -
225, The test varyKeysSeparatorMatchesMergedHeadersLookupSeparator is
tautological because both stored and lookup are produced with
joinHeaderValues(), so change the assertions to compare each result against the
exact RFC-compliant header-list string instead of against each other: compute
expected as the exact string (e.g. "application/vnd.github+json,
application/json" per RFC header-list spacing), then assertEquals(expected,
stored) and assertEquals(expected, lookup); reference HttpCacheEntry.varyKeys(),
mergedHeadersLookup(), and joinHeaderValues() to locate the code to update.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not important that the serialization complies with the RFC as this is not sent on the network. It's just an internal representation.

stored and lookup are both built via the same joinHeaderValues() call

that's the intention of the PR. To avoid an implicit dependency of 2 pieces of code that needs to be serialized with the same format. The new function forces an explicit dependency.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matteomazza91, that makes sense — the test is intentionally documenting the shared dependency on joinHeaderValues(), making it explicit that both varyKeys() and mergedHeadersLookup() must use the same function rather than two independent inline joins that could silently drift. The integration tests (cacheHitOccursWithContentNegotiationAndVaryAccept) provide the behavioral safety net if joinHeaderValues() itself ever regresses. Understood and acknowledged!

(ᵔᴥᵔ)🐇


🧠 Learnings used
Learnt from: bjhham
Repo: ktorio/ktor PR: 5345
File: ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RouteSelector.kt:694-699
Timestamp: 2026-02-05T10:32:08.803Z
Learning: In Kotlin sources, avoid annotating declarations with InternalAPI when they already have internal visibility, because internal already restricts access within the module. Apply this guidance across all Kotlin files (pattern **/*.kt). If a declaration is intended to be part of a public or exported API, or if InternalAPI conveys additional semantics beyond visibility, consider keeping the annotation and documenting the rationale.

Learnt from: nomisRev
Repo: ktorio/ktor PR: 5449
File: ktor-server/ktor-server-core/common/src/io/ktor/server/config/ApplicationConfig.kt:160-179
Timestamp: 2026-03-18T08:26:32.639Z
Learning: In the ktorio/ktor repository, apply a minimal KDoc style guideline for public API extension functions: include a brief description and a [Report a problem] link, and do not require explicit param, return, or throws tags. Do not flag missing KDoc param/return/throws as issues in this project; this guideline should apply to Kotlin source files across the codebase where public API surface is documented.

Learnt from: osipxd
Repo: ktorio/ktor PR: 5527
File: ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/NettyApplicationEngine.kt:187-187
Timestamp: 2026-05-06T08:59:44.186Z
Learning: In the ktorio/ktor repository, prefer kotlin.time.Duration over Long for time/duration-based parameters in all new public Kotlin APIs (e.g., timeout, delay, interval). Apply this guideline to all Kotlin source files (pattern **/*.kt). When a call site must pass milliseconds to Java/Netty APIs, convert using .inWholeMilliseconds. Update existing API signatures accordingly and add tests to cover Duration usage and the conversion boundary where needed.


/**
* Integration test: a fresh cacheable response (max-age=60, Vary: Accept) is never served
* from cache when ContentNegotiation is installed, because the comma/semicolon separator
* mismatch in findResponse() causes it to always discard the cached entry.
*/
@Test
fun cacheHitOccursWithContentNegotiationAndVaryAccept() =
runTest {
var serverCallCount = 0
val client = HttpClient(
MockEngine { _ ->
serverCallCount++
cacheableJsonResponse()
}
) {
install(HttpCache)
install(ContentNegotiation) {
register(ContentType.Application.Json, noOpJsonConverter)
}
}

val url = "https://example.com/api/resource"
client.get(url) { accept(ContentType.parse("application/vnd.github+json")) }
client.get(url) { accept(ContentType.parse("application/vnd.github+json")) }

// First response is cacheable (max-age=60) and the second request is identical.
// Expected: second request served from cache → serverCallCount == 1.
assertEquals(1, serverCallCount)
client.close()
}

/**
* Counterpart: without ContentNegotiation each header has a single value, so the
* "," vs ";" mismatch doesn't matter and the cache hit works correctly.
*/
@Test
fun freshCacheableEntryWithVaryAcceptIsServedFromCacheWhenContentNegotiationIsNotInstalled() =
runTest {
var serverCallCount = 0
val client = HttpClient(
MockEngine { _ ->
serverCallCount++
cacheableJsonResponse()
}
) {
install(HttpCache)
// ContentNegotiation intentionally absent: one Accept value → separator irrelevant
}

val url = "https://example.com/api/resource"
client.get(url) { accept(ContentType.parse("application/vnd.github+json")) }
client.get(url) { accept(ContentType.parse("application/vnd.github+json")) }

assertEquals(1, serverCallCount)
client.close()
}

private fun testApplication(block: suspend ApplicationTestBuilder.() -> Unit): TestResult {
return if (!PlatformUtils.IS_BROWSER) {
testApplication(EmptyCoroutineContext, block)
Expand Down