Problem/Motivation

Drupal's internal (anonymous) page cache keys responses by URL only (PageCache::getCacheId()): it ignores request headers, the response Vary header, and cache contexts, and it serves hits from middleware before routing runs. Header support is a known core limitation, tracked in #2430335: Browser language detection is not cache aware.

This module currently fires page_cache_kill_switch only when a language header is present on the request. That keeps negotiated responses out of the internal page cache, but does nothing for the mirror case: on a site with the internal page cache enabled, an anonymous request without Accept-Language warms the URL with the default-language body, and every later anonymous request to the same URL with Accept-Language is served that cached default-language body before negotiation ever runs. The strict-negotiation contract silently degrades to default-language for anonymous traffic. (langCode is unaffected: it is part of the URL and therefore of the cache key.)

The hole also exists in the other direction on error paths, because core's page cache stores 4xx client-error responses too:

  • The config-entity strict 406 is thrown before any kill switch fires, unlike the content path.
  • The /related/ to-one strict miss (404/406) is thrown before that path's kill-switch trigger.

A stored header-triggered error could then be replayed to a selector-free request for the same URL, and vice versa.

Steps to reproduce

  1. Site with the page_cache module enabled (core default), languages en (default) and fr, a translatable article with an fr translation, anonymous users may view it.
  2. As anonymous: GET /jsonapi/node/article/{uuid} with no Accept-Language header. Response: 200, body langcode en, stored by the internal page cache.
  3. As anonymous: the same URL with Accept-Language: fr. Expected: the fr translation. Actual: the cached en body.
  4. As anonymous: the same URL with Accept-Language: de (no translation). Expected: 406 Not Acceptable. Actual: the cached en body.

Proposed resolution

Trigger the page-cache kill switch unconditionally for every read of a translatable content resource, a config resource, and every jsonapi_menu_items request, whether or not the request carries a language selector, and before any negotiation or thrown error in each path. No response for a language-capable URL is then ever stored where a URL-keyed cache could mis-serve it, in either direction.

  • The dynamic page cache is unaffected: it honours the already-declared url.query_args:langCode and headers:Accept-Language cache contexts and keeps serving these reads correctly, including for anonymous users. Implementation note: core's page_cache_kill_switch service is tagged into both the internal and dynamic page-cache policy chains, so triggering it unconditionally would disable both caches (this is why an unconditional trigger historically broke core's testMetaEvent). The module therefore registers its own instance of the same KillSwitch class tagged only into page_cache_response_policy and triggers that, denying the URL-keyed cache alone.
  • The trade-off is deliberate: anonymous internal-page-cache hits are lost for translatable JSON:API reads. Correctness of the contract outweighs that optimization; decoupled clients typically authenticate and bypass the internal page cache anyway.
  • The alternative of documenting "disable the internal page cache" as a deployment requirement was rejected: a silently wrong-language response on a default install is a defect, not a footnote.
  • Removal condition: when core #2430335 lands header support in the internal page cache, the unconditional triggers can be revisited.
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 committed 1e137494 on 1.0.x
    fix: #3613686 Internal page cache serves default-language bodies to...
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.