Skip to content

LocaleFromHeader drops region-qualified top Accept-Language preference (ca-ES loses to es) #1973

@davidpoblador

Description

@davidpoblador

Summary

The Accept-Language locale selector ignores a region-qualified top
preference and serves a lower-ranked exact match instead. A Catalan
browser sending Accept-Language: ca-ES,es;q=0.9,en;q=0.8 is served
Castilian (es) instead of Catalan (ca).

Root cause

starlette_babel.locale.LocaleFromHeader.__call__ matches each header
token against the supported set by full identifier only:

for lang, _ in self._get_languages_from_header(header):
    lang = lang.lower().replace("-", "_")   # "ca-ES" -> "ca_es"
    if lang == "*":
        break
    if lang in self.supported_locales:       # supported = ["ca","es",...]
        return lang
return None

With supported locales ["ca","es","eu","nl","sv","en"], the top token
ca-ES normalizes to ca_es, which is not in the list, so it is
silently dropped. Iteration continues to es;q=0.9, an exact match, so
es wins. The q=1.0 Catalan preference loses to the q=0.9 Castilian one.

There is no language-only subtag fallback (ca_es -> ca).

Reproduction

from starlette_babel.locale import LocaleFromHeader

class Conn:
    def __init__(self, h): self.headers = {"accept-language": h}

sel = LocaleFromHeader(supported_locales=["ca","es","eu","nl","sv","en"])
print(sel(Conn("ca-ES,es;q=0.9,en;q=0.8")))  # -> "es"   (expected "ca")
print(sel(Conn("ca-ES")))                    # -> None   (expected "ca")

Note on negotiate_locale

starlette_babel.locale.negotiate_locale (babel-based) does not rescue
this either, because the parsed preferred tokens are lowercased with
hyphens (ca-es) rather than babel's ca_ES, so babel's language-only
match never fires:

negotiate_locale(["ca-es","es","en"], ["ca","es","eu","nl","sv","en"])  # -> "es"

Expected

Each preferred token should fall back to its language-only subtag when
the full tag is unsupported, so ca-ES matches supported ca. The
highest-ranked token with any (full or language-only) supported match
should win.

Workaround in use (downstream)

We registered a custom resolver via register_locale_resolver that does
region-aware negotiation and defers to the higher-priority selectors
(query / URL prefix / user session / cookie) so only the Accept-Language
layer is corrected.

Filed by Claude Code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions