Skip to content

Keep Accept-Language in Vary when no offered language is acceptable - #36

Merged
namedgraph merged 4 commits into
masterfrom
rf-vary-accept-language
Sep 2, 2026
Merged

Keep Accept-Language in Vary when no offered language is acceptable#36
namedgraph merged 4 commits into
masterfrom
rf-vary-accept-language

Conversation

@namedgraph

Copy link
Copy Markdown
Member

Problem

Response selected a variant twice: request.selectVariant(variants), and on null a retry with request.selectVariant(removeLanguages(variants)).

ContainerRequest.selectVariant assigns its varyValue field on every call, and ContainerResponse builds the Vary header from whatever the last call left there — skipping the header entirely when varyValue is null. The retry therefore published a Vary derived from a variant list with no language dimension, so only a request whose Accept-Language matched an offered language got the dimension at all.

The entity was still negotiated over Accept-Language and its content still depended on it, so a shared cache was free to store one language's representation and serve it to a client that asked for another. Observed downstream in LinkedDataHub, where server-side XSLT renders over the full q-sorted acceptable-language list:

Accept-Language: en                                 Vary: Accept-Charset,Accept,Accept-Language
Accept-Language: lt                                 Vary: Accept-Charset,Accept
Accept-Language: en,lt                              Vary: Accept-Charset,Accept
Accept-Language: en-US,en;q=0.9,da;q=0.8,lt;q=0.7   Vary: Accept-Charset,Accept

Only an exact match on the one offered language was correct; every real browser header was not.

Why not just reorder the two calls

Selecting over the full list last leaves varyValue null whenever nothing matches, and the response then carries no Vary at all — worse than an incomplete one. Verified rather than assumed: with the calls reordered, the lt request came back with no Vary header.

Fix

The language-neutral representations join the offer instead of replacing it, and a single selection pass serves both purposes:

  • the dimension stays in Vary, because the list still declares languages
  • a request accepting none of the offered languages still gets a representation, because the list also offers language-neutral ones
  • a request that does match an offered language still selects the language-specific variant, so Content-Language and the language-specific ETag are unchanged

Tests

LocaleEntityTagTest.testVaryIncludesAcceptLanguage covers three cases: a request accepting the offered language, one accepting only a language that is not offered, and a multi-entry header of the kind browsers actually send. It fails on the previous implementation — Vary comes back without the dimension — and passes on this one.

The existing testLocales guards Content-Language and the language-specific ETag against the neutral variant winning, and still passes. Full suite: 61 tests, no failures.

🤖 Generated with Claude Code

https://claude.ai/code/session_0184W2B82P2wUBntUPie223L

namedgraph and others added 4 commits September 1, 2026 00:16
…fered language is acceptable.

Variant selection ran twice: request.selectVariant(variants), and on null a retry with request.selectVariant(removeLanguages(variants)). ContainerRequest.selectVariant assigns its varyValue field on every call, and ContainerResponse builds the Vary header from whatever the last call left there - skipping the header entirely when varyValue is null. So the retry published a Vary derived from a variant list with no language dimension, and only a request whose Accept-Language matched an offered language got the dimension at all. The entity had still been negotiated over Accept-Language and its content still depended on it, so a shared cache was free to store one language's representation and serve it to a client that asked for another.

Reordering the two calls does not fix it: selecting over the full list last leaves varyValue null whenever nothing matches, and the response then carries no Vary at all - worse than an incomplete one. Verified, not assumed: with the calls reordered the lt request came back with no Vary header.

So the language-neutral representations join the offer instead of replacing it, and one selection pass serves both purposes. The dimension stays in Vary because the list still declares languages, and a request accepting none of the offered languages still gets a representation because the list also offers language-neutral ones. A request that does match an offered language still selects the language-specific variant, so Content-Language and the language-specific ETag are unchanged - LocaleEntityTagTest.testLocales covers that and still passes.

testVaryIncludesAcceptLanguage covers the three cases: a request accepting the offered language, one accepting only a language that is not offered, and a multi-entry header of the kind browsers actually send. It fails on the previous implementation - Vary comes back without the dimension - and passes on this one. Full suite: 61 tests, no failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…language.

getVariantEntityTag hashed the content plus the selected variant, and the variant carries at most one language. For a language-significant media type that is the wrong granularity: the entity is rendered against the whole acceptable-language list, falling back per value, so two requests that select the same language-neutral variant are still different representations. With "lt" and "de" against an offer of English, one renders the Lithuanian values the data holds and the other falls back to English - byte-different pages under one strong ETag. A conditional request could be answered 304 with the wrong language.

The acceptable languages now go into the hash when the media type is language-significant, which is what the isMediaTypeLangSignificant predicate already claimed to govern - its javadoc says the language is preserved in the ETag calculation, and until now only the variant's was.

They arrive through a new constructor overload rather than a changed signature: jakarta.ws.rs.core.Request does not expose them, and Response is constructed from a dozen call sites across Web-Client and LinkedDataHub. Callers that supply nothing get exactly the previous entity tag, so this is additive - the test asserts that too, alongside a media type whose rendering does not depend on language being unaffected.

Verified by removing the addition and watching the test fail with the two tags equal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…ly use.

The previous commit added the accept list to the entity tag through a new terminal-constructor overload, but every caller in Web-Client and LinkedDataHub builds a Response from media types, languages and encodings rather than from a selected Variant - so nothing could supply the list, and the entity tag went on ignoring language. Verified live before this: with the fix deployed, Accept-Language lt and de still shared ETag "bf450a1e36535bd0" while returning 25763 and 25710 bytes.

The overload threads the list through the form those callers use. Still additive - the existing constructor delegates with an empty list and produces exactly the entity tag it did before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
@namedgraph
namedgraph merged commit 6a12f87 into master Sep 2, 2026
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