The GraphQL search surface varies its response by Accept-Language but sends neither Vary: Accept-Language nor Content-Language, so any shared cache or CDN in front of a deployment may serve one requester’s language to the next.
What varies by the header
parseAcceptLanguage in packages/search-api-graphql/src/handler.ts puts the parsed preference list on SearchContext, and it reaches two places:
- Display ordering –
toLanguageStrings(value, context.acceptLanguage, languageOrder) in build-schema.ts, documented at build-schema.ts:100 as “defaults to Accept-Language-first, und last”. name[0] therefore differs per requester.
- Search and sort –
packages/search/src/query.ts:30 selects the per-locale stemmed and sort fields from the same list, so result ranking and order differ too.
What is missing
Content-Language and Vary appear nowhere under packages/. Two consequences:
- Cache poisoning across languages. Without
Vary: Accept-Language, an intermediary treats two requests differing only in that header as the same entry. A Dutch-first response can be served to an English-first client, or vice versa – silently, since nothing in the payload states which ordering was applied.
- The client cannot tell what it got.
[0].language reveals the language of each individual value, but nothing reports which preference the server actually honoured for the response as a whole.
Suggested fix
Emit both from the handler:
Vary: Accept-Language on every search response (unconditionally – it describes the resource’s negotiation behaviour, not one response’s outcome).
Content-Language listing the languages actually present in the response. RFC 9110 §8.5 defines it as the languages of the intended audience and explicitly allows multiple values, so a mixed-language result page can be described honestly rather than collapsed to one tag.
Never fail negotiation: the current fallback behaviour (to und when no header is given, per build-schema.test.ts:813) is correct and should stay. Accept-Language is a preference here, not a precondition.
Context
Found while reviewing the NDE generic API specification against LDE and the NDE Stack. That specification mandates both headers (its Languages rules, items 4 and 6) while getting the negotiation semantics wrong in the other direction – it requires 406 when a requested language is unsupported, and forbids serving multiple languages in one response, neither of which survives contact with a corpus that is ~86% Dutch-only with routinely untagged proper names. The header emission is the half worth taking from it.
The GraphQL search surface varies its response by
Accept-Languagebut sends neitherVary: Accept-LanguagenorContent-Language, so any shared cache or CDN in front of a deployment may serve one requester’s language to the next.What varies by the header
parseAcceptLanguageinpackages/search-api-graphql/src/handler.tsputs the parsed preference list onSearchContext, and it reaches two places:toLanguageStrings(value, context.acceptLanguage, languageOrder)inbuild-schema.ts, documented atbuild-schema.ts:100as “defaults to Accept-Language-first,undlast”.name[0]therefore differs per requester.packages/search/src/query.ts:30selects the per-locale stemmed and sort fields from the same list, so result ranking and order differ too.What is missing
Content-LanguageandVaryappear nowhere underpackages/. Two consequences:Vary: Accept-Language, an intermediary treats two requests differing only in that header as the same entry. A Dutch-first response can be served to an English-first client, or vice versa – silently, since nothing in the payload states which ordering was applied.[0].languagereveals the language of each individual value, but nothing reports which preference the server actually honoured for the response as a whole.Suggested fix
Emit both from the handler:
Vary: Accept-Languageon every search response (unconditionally – it describes the resource’s negotiation behaviour, not one response’s outcome).Content-Languagelisting the languages actually present in the response. RFC 9110 §8.5 defines it as the languages of the intended audience and explicitly allows multiple values, so a mixed-language result page can be described honestly rather than collapsed to one tag.Never fail negotiation: the current fallback behaviour (to
undwhen no header is given, perbuild-schema.test.ts:813) is correct and should stay.Accept-Languageis a preference here, not a precondition.Context
Found while reviewing the NDE generic API specification against LDE and the NDE Stack. That specification mandates both headers (its Languages rules, items 4 and 6) while getting the negotiation semantics wrong in the other direction – it requires
406when a requested language is unsupported, and forbids serving multiple languages in one response, neither of which survives contact with a corpus that is ~86% Dutch-only with routinely untagged proper names. The header emission is the half worth taking from it.