Problem/Motivation

Reads currently negotiate their language from the Accept-Language request header. Carrying language state in a header makes every translatable response effectively uncacheable at every layer:

  • The internal (anonymous) page cache keys responses by URL only and ignores request headers (core #2430335, open since 2015), so the module must trigger a page-cache kill switch unconditionally on every translatable read. Anonymous JSON:API traffic gets zero internal page caching.
  • The dynamic page cache varies by the headers:Accept-Language cache context, whose cardinality is unbounded: browsers send highly variable values (en-US,en;q=0.9, fr-FR,fr;q=0.9,en;q=0.8, ...), one cache entry each.
  • Vary: Accept-Language fragments every Vary-honouring reverse proxy / CDN cache per browser language string.

Worse, the page cache stores 4xx responses by URL for cache_ttl_4xx (an hour by default), ignoring headers and cache contexts, so any header-triggered error on an otherwise-good URL is a cache-poisoning vector: one stray header poisons that URL's cached response for every later anonymous client.

Separately, Accept-Language puts the fallback policy in the client's hands (an ordered accept-list), while Drupal already has a site-owned fallback policy: the language fallback candidates system, which modules such as language_hierarchy configure, and which the rendered HTML site already follows. The API and the rendered site should fall back identically.

Steps to reproduce

  1. Install the module on a multilingual site with the internal page cache enabled and inspect any translatable JSON:API read: it is never stored (X-Drupal-Cache: UNCACHEABLE (response policy)), because the kill switch must fire on every translatable read to keep the URL-keyed cache from serving one language's body to another language's request.
  2. Behind a Vary-honouring proxy, watch the dynamic page cache and CDN fragment per distinct browser Accept-Language string for the same URL.

Proposed resolution

Move all read-language state into the URL and defer fallback to the backend:

  • Reads select their language exclusively with the existing langCode query parameter. Strict semantics are unchanged: a missing translation is a 404 (individual) or an exclusion (collections, /related/, menu items, with meta.omitted reporting). The strict 404 becomes cacheable, carries the missed entity's cacheability (creating the translation invalidates the cached miss through normal tag invalidation), and advertises the viewable translations in the error detail and as meta.availableTranslations (taken over from the removed 406).
  • A new includeFallback=1 query parameter (reads only, requires langCode, only the literal value 1 so cache variants stay canonical) resolves the best available translation through the site's language fallback chain: core's LanguageManager::getFallbackCandidates() as applied by EntityRepository::getTranslationFromContext(). The module implements no fallback policy of its own, so hook_language_fallback_candidates_alter() implementers (language_hierarchy) compose automatically, and the API falls back identically to the rendered site. The entity's default translation is the terminal candidate, so an individual fallback read of an existing entity always returns 200; a fallback collection includes every viewable item, each in its chain-resolved language. Config entities serve override-or-base through the config override layer (where language_hierarchy's chained override provider applies transparently). Fallback-mode responses carry the config:configurable_language_list cache tag, so rewiring the chain invalidates cached fallback bodies.
  • Language request headers become inert on reads, Content-Language exactly like Accept-Language: no read outcome (a selection or a rejection alike) may depend on a request header, per the poisoning vector above. A read carrying only language headers gets core's default-language behaviour, exactly as stock core JSON:API. Content-Language keeps its selecting and validating role on writes, which are never page-cached.
  • Removed outright: the strict Accept-Language negotiation and its 406 Not Acceptable path, the Content-Location canonicalization, the site-wide Vary handling, and the page-cache kill switch. Responses declare their language variance with the url.query_args:langCode and url.query_args:includeFallback cache contexts, declared unconditionally on translatable responses; cache cardinality is bounded at (configured languages + 1) × 2 variants per URL, and every read is cacheable by URL at every layer, CDNs included.
  • The jsonapi_menu_items companion route follows the same two-mode contract, and the optional OpenAPI submodule documents the two query parameters instead of the read header and the 406.

Implementation notes:

  • The meta.availableTranslations error injection runs at response priority 100, before the dynamic page cache stores the body (7), so cached 404s carry the meta.
  • The unknown-langCode 422 is cacheable and carries the config:configurable_language_list tag, so configuring the language un-caches the rejection.
  • Test coverage includes a test module mirroring language_hierarchy's exact storage and hook (a fallback_langcode third-party setting on language.entity.* applied via hook_language_fallback_candidates_alter()), and a positive internal-page-cache suite replacing the old poisoning regression test: variants cached per URL, cached strict 404s invalidated by translation creation, cached fallback bodies invalidated by translation creation and chain rewires.

Remaining tasks

  • Review the merge request.
  • Retire the now-obsolete sibling issues built on the header contract (strict no-fallback negotiation, Vary scoping, collection Accept-Language cache context) in favour of this one.
  • Propose the URL-keyed contract on core #3199697: the caching argument (the page cache is URL-keyed and #2430335 is over a decade old) applies to core's planned Accept-Language read negotiation identically.

API changes

  • BREAKING for clients that negotiated reads via Accept-Language: the header is now ignored on reads (a header-only read returns the default translation with 200, exactly like stock core JSON:API, instead of a negotiated translation or a 406). Migration: put the language in the URL, ?langCode=<lang> for the strict read, plus &includeFallback=1 to defer fallback to the site's chain.
  • New includeFallback query parameter on translatable reads (individual, collection, /related/, menu items).
  • The 406 Not Acceptable status and the Content-Location / Vary: Accept-Language response headers are removed; the strict 404 gains meta.availableTranslations.
  • A Content-Language header on a cacheable request is no longer rejected with 400; it is ignored. Writes are unchanged.
Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

lauriii created an issue. See original summary.

lauriii’s picture

Issue summary: View changes
lauriii’s picture

Issue summary: View changes

  • lauriii committed 6ed2500e on 1.0.x
    task: #3613823 Replace Accept-Language read negotiation with URL-keyed...
lauriii’s picture

Status: Active » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.