Skip to content

Skip Accept-Language tags that match no supported locale - #19

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

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

Conversation

@rubas

@rubas rubas commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

Before

With supported_locales: [:"de-CH", :"en-CH", :"fr-CH", :"it-CH"] and default_locale: :"de-CH":

iex> {:ok, tag} = Localize.AcceptLanguage.best_match("es-ES,fr-CH;q=0.9,en;q=0.8")
iex> {Localize.LanguageTag.to_string(tag), tag.language, tag.cldr_locale_id}
{"es-ES", :es, :"de-CH"}
Accept-Language Result today (tag, language, data)
es-ES,fr-CH;q=0.9,en;q=0.8 es-ES, :es, :"de-CH"
ja,en;q=0.9 ja, :ja, :"de-CH"
pt-BR,en;q=0.8 pt-BR, :pt, :"de-CH"
gsw-CH,de;q=0.9 gsw-CH, :gsw, :"de-CH"
rm-CH rm-CH, :rm, :"de-CH"

Problem

  • best_match/1 takes the first tag in the header. Localize.validate_locale/1 falls back to the first supported locale when nothing is close, so the not is_nil(id) guard never rejects a tag and the later candidates (fr-CH, en) are never tried.
  • The returned tag says one language and carries data for another (language: :es, cldr_locale_id: :"de-CH"). PutLocale stores it in the session and Gettext gets de, while a Spanish/French visitor who listed French second is served German.
  • The :accept_language option documents "the best matched configured locale".

After

iex> {:ok, tag} = Localize.AcceptLanguage.best_match("es-ES,fr-CH;q=0.9,en;q=0.8")
iex> {Localize.LanguageTag.to_string(tag), tag.language, tag.cldr_locale_id}
{"fr-CH", :fr, :"fr-CH"}
Accept-Language Result now (tag, language, data)
es-ES,fr-CH;q=0.9,en;q=0.8 fr-CH, :fr, :"fr-CH"
ja,en;q=0.9 en, :en, :"en-CH"
pt-BR,en;q=0.8 en, :en, :"en-CH"
gsw-CH,de;q=0.9 de-CH, :de, :"de-CH"
rm-CH de-CH, :de, :"de-CH"
en-US,en;q=0.9 en-US, :en, :"en-CH" (unchanged)
zh-TW {:error, %Localize.UnknownLocaleError{}}, so PutLocale moves on to its next source or the default
Details

Cause: Localize.AcceptLanguage.best_match/1 in lib/localize/accept_language.ex accepted any tag that Localize.validate_locale/1 resolved. That function always resolves a valid tag (its docs say so under "Valid tags always match"), because Localize.LanguageTag.best_match/2 falls back to the first supported locale at its default threshold of 80.

Design: after validate_locale/1, check the resolved cldr_locale_id with Localize.LanguageTag.best_match(requested, [requested.cldr_locale_id], 79). The distance between unrelated languages is 80, so 79 keeps every real CLDR match (en-US to en-CH at 5, gsw to de at 4, rm to de at 20) and rejects only the fallback. Scoring the one resolved id keeps the per-request cost small, also when supported_locales is not configured. When CLDR matches across languages (gsw, rm), the tag of the supported locale is returned, so language matches the served data; same-language matches keep the requested tag (en-US with en-CH data), as before. Threshold 0, which the validate_locale/1 docs suggest for strict checks, would reject en-US against en-CH.

Tests: test/accept_language_test.exs. Four existing tests asserted the fallback: Romanian, Polish and Spanish are not in the test supported_locales, and each expected the unsupported language back (with :en data). They now expect the next supported candidate. New tests cover es-ES,fr-CH to fr, gsw-CH to de, and an error when no tag matches. The new and updated tests fail on main. Also checked with localize 1.3.0 and a de-CH/en-CH/fr-CH/it-CH config through PutLocale (the tables above).

Checks: mix test (270 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.

Localize.validate_locale/1 falls back to the first supported locale for
a tag with no close match, so best_match/1 always took the first tag.
Accept a tag only when CLDR matches it within the distance of unrelated
languages, and return the supported locale when that match crosses
languages.
@kipcole9
kipcole9 merged commit 7b1c61f 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