Skip to content

Fix cache misses with content negotiation#5623

Open
Matteo Mazza (matteomazza91) wants to merge 2 commits into
ktorio:mainfrom
matteomazza91:fix-cache-misses-with-content-negotiation
Open

Fix cache misses with content negotiation#5623
Matteo Mazza (matteomazza91) wants to merge 2 commits into
ktorio:mainfrom
matteomazza91:fix-cache-misses-with-content-negotiation

Conversation

@matteomazza91

Copy link
Copy Markdown
Contributor

Subsystem
Client, HttpCache

Motivation
When a Vary header contains multiple values, requests always generate cache-misses and If-None-Match / If-Modified-Since are never sent

Solution
Centralize multi-values header serialization in one place to force the same serialization approach (i.e. use , instead of ;)

Use Case

When HttpCache and ContentNegotiation are both installed, responses with Accept in the Vary header (e.g. Vary: Accept) are never served from cache if the client specifies a specific Accept header.

e.g. to make requests to github, the client should use Accept: application/vnd.github+json but the ContentNegotiation plugin also unconditionally appends an Accept for every registered codec:

registrations.forEach { request.accept(it.contentTypeToSend) }

When the caller also sets Accept (e.g. accept(ContentType.parse("application/vnd.github+json"))), the request ends up with two Accept values. The stored varyKey and the lookup key then differ only in separator:

Value
Stored (joinToString(",")) "application/vnd.github+json,application/json"
Lookup (joinToString(";")) "application/vnd.github+json;application/json"

Ktor version

Reproduced on 3.5.0-dev (main branch as of 2026-05-15) and 2.3.7.

In 2.3.7: varyKeys() stored values using Headers.get() (first value only) while mergedHeadersLookup used getAll().joinToString(";"). An earlier fix changed get() to getAll().joinToString(",") but left the separator in mergedHeadersLookup as ";", so the mismatch persists.

Platform / engine

JVM — the bug is in ktor-client-core, not engine-specific.

Steps to reproduce

Option A — Checkout the failing tests from this fork

git clone https://github.com/ktorio/ktor.git
cd ktor
# Check out the commit that adds the three failing tests:
git checkout 6b806fb2d

# Run only the relevant test class (uses MockEngine, no server needed):
./gradlew :ktor-client-core:jvmTest \
  --tests "HttpCacheTest.varyKeysStoredWithCommaSeparatorButMergedHeadersLookupUsesSemicolon" \
  --tests "HttpCacheTest.freshCacheableEntryWithVaryAcceptIsNeverServedFromCacheWhenContentNegotiationIsInstalled" \
  --tests "HttpCacheTest.freshCacheableEntryWithVaryAcceptIsServedFromCacheWhenContentNegotiationIsNotInstalled"

Expected: 2 tests fail (the unit test and the integration test), 1 passes (the counterpart without ContentNegotiation).

Expected behaviour

The second GET should be served from in-memory cache (max-age=60 has not expired). serverCallCount == 1.

Actual behaviour

serverCallCount == 2. The cache entry is stored after the first request but never matched on subsequent requests.

…otiation is installed

Three tests demonstrating that HttpCache misses the cache for every request when
ContentNegotiation is installed and the response includes a Vary header.

Root cause: varyKeys() stores multi-value headers joined with "," while
mergedHeadersLookup() joins them with ";". When ContentNegotiation appends a
second Accept value the stored "a,b" never equals the looked-up "a;b", so
findResponse() always returns null.

- varyKeysStoredWithCommaSeparatorButMergedHeadersLookupUsesSemicolon: fails,
  directly showing the separator divergence (expected to fail before the fix)
- freshCacheableEntryWithVaryAcceptIsNeverServedFromCacheWhenContentNegotiationIsInstalled:
  fails, serverCallCount==2 instead of 1
- freshCacheableEntryWithVaryAcceptIsServedFromCacheWhenContentNegotiationIsNotInstalled:
  passes, confirms the cache works with a single Accept value
@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Normalizes multi-value header joining (RFC 7230) and aligns cache lookup/validation: adds joinHeaderValues(), updates varyKeys() and mergedHeadersLookup(), and adds tests plus ContentNegotiation test dependency to ensure consistent Vary: Accept cache behavior.

Changes

Cache Vary header normalization

Layer / File(s) Summary
Header value joining helper and varyKeys() update
ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/cache/HttpCacheEntry.kt
Introduces internal joinHeaderValues() helper and refactors HttpResponse.varyKeys() to use it for RFC-7230 compliant comma-joined header strings.
Cache lookup header value normalization
ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/cache/HttpCache.kt
Updates mergedHeadersLookup() to obtain header lists from response/request and use joinHeaderValues() when producing merged header lookup strings.
Test imports and build configuration for ContentNegotiation
ktor-client/ktor-client-core/build.gradle.kts, ktor-client/ktor-client-core/common/test/HttpCacheTest.kt
Adds projects.ktorClientContentNegotiation as a commonTest dependency and expands test imports; introduces test helpers and a no-op ContentConverter for generating cacheable JSON responses with Vary: Accept.
Regression and integration tests for Vary: Accept
ktor-client/ktor-client-core/common/test/HttpCacheTest.kt
Adds a unit test verifying varyKeys() and mergedHeadersLookup() use matching join semantics for multi-value Accept headers, plus two integration tests asserting cached responses are served across identical requests with and without ContentNegotiation installed.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • ktorio/ktor#5030: Both PRs modify HttpCacheEntry’s varyKeys() logic to normalize/concatenate multi-value Vary header values and add related tests.
  • ktorio/ktor#4816: Both PRs adjust client HTTP cache Vary handling and related vary-key derivation/normalization.

Suggested reviewers

  • marychatte
  • osipxd
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix cache misses with content negotiation' directly describes the main issue being addressed: cache misses that occur when ContentNegotiation is installed alongside HttpCache, which is the primary bug fix in this changeset.
Description check ✅ Passed The pull request description comprehensively covers all required sections with clear motivation (cache misses with multi-value Vary headers), a detailed solution (centralizing header serialization), and extensive documentation of the use case and reproduction steps.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
ktor-client/ktor-client-core/common/test/HttpCacheTest.kt (1)

233-257: 💤 Low value

Consider renaming test to describe expected behavior rather than the bug.

The test name freshCacheableEntryWithVaryAcceptIsNeverServedFromCacheWhenContentNegotiationIsInstalled describes the bug behavior (never served from cache), but the assertion assertEquals(1, serverCallCount) verifies the fix (cache hit occurs). This creates confusion about what the test is validating.

Consider renaming to reflect the expected behavior after the fix, for example:

  • freshCacheableEntryWithVaryAcceptIsServedFromCacheWhenContentNegotiationIsInstalled
  • cacheHitOccursWithContentNegotiationAndVaryAccept

Also, the comment at lines 252-254 uses "Actual:" to describe the old buggy behavior, which is confusing since "actual" typically refers to the current test result. Consider rephrasing to "Before fix:" or "Previously:".

🤖 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 233 -
257, Rename the test function
freshCacheableEntryWithVaryAcceptIsNeverServedFromCacheWhenContentNegotiationIsInstalled
to reflect the expected/verified behavior (e.g.
freshCacheableEntryWithVaryAcceptIsServedFromCacheWhenContentNegotiationIsInstalled
or cacheHitOccursWithContentNegotiationAndVaryAccept) and update the inline
comment that currently reads "Actual:" to something like "Before fix:" or
"Previously:" so the comment no longer conflicts with the assertion that
serverCallCount == 1; adjust occurrences where the test name is referenced
(e.g., test runners) so the symbol change is consistent.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@ktor-client/ktor-client-core/common/test/HttpCacheTest.kt`:
- Around line 233-257: Rename the test function
freshCacheableEntryWithVaryAcceptIsNeverServedFromCacheWhenContentNegotiationIsInstalled
to reflect the expected/verified behavior (e.g.
freshCacheableEntryWithVaryAcceptIsServedFromCacheWhenContentNegotiationIsInstalled
or cacheHitOccursWithContentNegotiationAndVaryAccept) and update the inline
comment that currently reads "Actual:" to something like "Before fix:" or
"Previously:" so the comment no longer conflicts with the assertion that
serverCallCount == 1; adjust occurrences where the test name is referenced
(e.g., test runners) so the symbol change is consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 00797568-3f25-4fae-8a1e-089603bf9e98

📥 Commits

Reviewing files that changed from the base of the PR and between ed94135 and 370cfd9.

📒 Files selected for processing (4)
  • ktor-client/ktor-client-core/build.gradle.kts
  • ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/cache/HttpCache.kt
  • ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/cache/HttpCacheEntry.kt
  • ktor-client/ktor-client-core/common/test/HttpCacheTest.kt

…ry.varyKeys()

mergedHeadersLookup() joined multi-value headers with ";" while varyKeys()
joined them with ",", so findResponse() always discarded cached entries for
requests with repeated header values (e.g. when ContentNegotiation appends a
second Accept value).

Extract joinHeaderValues() (RFC 7230 §3.2.2 comma separator) and use it in
both places so the stored and looked-up strings are always identical.
@matteomazza91 Matteo Mazza (matteomazza91) force-pushed the fix-cache-misses-with-content-negotiation branch from 370cfd9 to 3b02ff7 Compare May 18, 2026 09:31

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
ktor-client/ktor-client-core/common/test/HttpCacheTest.kt (1)

210-210: ⚡ Quick win

Use descriptive backticked test names to match repo test style.

These new test names should be converted to backticked descriptive names per the test-file guideline.

As per coding guidelines: "**/*Test.{kt,kts}: Prefer descriptive test names in backticks: describe what is being tested".

Also applies to: 233-233, 263-263

🤖 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` at line 210,
Rename the test function varyKeysSeparatorMatchesMergedHeadersLookupSeparator to
a descriptive backticked test name (e.g. `describe what is being tested`) to
follow the file's test naming style; likewise update the two other tests called
out in the review to use backticked descriptive names instead of plain function
identifiers so all tests in HttpCacheTest.kt follow the "`describe what is being
tested`" convention.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@ktor-client/ktor-client-core/common/test/HttpCacheTest.kt`:
- Around line 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.

---

Nitpick comments:
In `@ktor-client/ktor-client-core/common/test/HttpCacheTest.kt`:
- Line 210: Rename the test function
varyKeysSeparatorMatchesMergedHeadersLookupSeparator to a descriptive backticked
test name (e.g. `describe what is being tested`) to follow the file's test
naming style; likewise update the two other tests called out in the review to
use backticked descriptive names instead of plain function identifiers so all
tests in HttpCacheTest.kt follow the "`describe what is being tested`"
convention.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ac9cea0d-4388-457a-b536-326cad77ba6c

📥 Commits

Reviewing files that changed from the base of the PR and between 370cfd9 and 3b02ff7.

📒 Files selected for processing (3)
  • ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/cache/HttpCache.kt
  • ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/cache/HttpCacheEntry.kt
  • ktor-client/ktor-client-core/common/test/HttpCacheTest.kt
✅ Files skipped from review due to trivial changes (1)
  • ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/cache/HttpCacheEntry.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/cache/HttpCache.kt

Comment on lines +210 to +225
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)
}

@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.

@matteomazza91

Copy link
Copy Markdown
Contributor Author

Actionable comments posted: 1

🧹 Nitpick comments (1)

ktor-client/ktor-client-core/common/test/HttpCacheTest.kt (1)> 210-210: ⚡ Quick win

Use descriptive backticked test names to match repo test style.

test names are matching the style of the file where they've been added

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant