Skip to content

Set Vary for the request headers PutLocale reads - #21

Merged
kipcole9 merged 1 commit into
elixir-localize:mainfrom
rubas:fix/vary-accept-language
Sep 27, 2026
Merged

kipcole9 merged 1 commit into
elixir-localize:mainfrom
rubas:fix/vary-accept-language

Conversation

@rubas

@rubas rubas commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

Before

plug Localize.Plug.PutLocale, from: [:session, :accept_language], two requests to the same URL with an empty session:

accept-language: fr -> locale fr, vary: []
accept-language: de -> locale de, vary: []

Problem

  • The same URL returns French or German depending on request headers, but the response does not say so. A shared cache (CDN, reverse proxy) that stores it serves the first visitor's language to everyone.
  • The default Plug cache-control (max-age=0, private, must-revalidate) keeps compliant shared caches out, so this only hurts apps that turn on public caching. Those apps have no easy way to know which headers PutLocale read.

After

accept-language: fr -> locale fr, vary: ["cookie, accept-language"]
accept-language: de -> locale de, vary: ["cookie, accept-language"]

PutLocale adds accept-language for :accept_language and cookie for :session and :cookie, for every one of these sources it read, not only the one that decided: with from: [:cookie, :query], ?locale=de still gives vary: cookie, because a cookie would have won. Sources that are part of the URL (:query, :path, :route, :host) add nothing. Existing values are kept, matched case-insensitively, and vary: * is left alone.

Details

Cause: Localize.Plug.PutLocale.call/2 in lib/localize/plug/put_locale.ex never set vary.

Design: locale_from_params/3 now also returns the sources it consulted, and put_vary/2 maps them to header names and merges them into the existing vary value. This replaces return_if_valid_locale/1. On by default, not opt-in: a missing vary gives wrong content from a public cache, while an extra vary only splits that cache, and with the default private cache-control it changes nothing. This follows Plug.Static, which sets vary: Accept-Encoding when it negotiates the encoding. An app that wants a better hit rate can put the locale in the URL (:route, :path, :query). The moduledoc says that a {Module, function} source that reads a header must add its own vary.

Tests: new call/2 - Vary response header tests in test/plug/put_locale_test.exs: header-decided, a query win after an empty cookie, the default after an empty cookie and an invalid header, a URL-only win, merging with Accept-Encoding, and *. They fail on main.

Checks: mix test (273 passed), mix format --check-formatted, mix compile --warnings-as-errors. mix credo --strict reports only the existing Sigil_q.InterpolationTest module-name issue.


AI disclosure: AI models helped us find this issue, write the change, and review it.

A locale taken from Accept-Language, the session or a cookie changes
the response for the same URL, so a shared cache must key it on those
headers. Add accept-language and cookie for each such source consulted,
merged with any existing Vary value.
@kipcole9
kipcole9 merged commit 5b9873c into elixir-localize:main Sep 27, 2026
10 checks passed
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.

2 participants