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.
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.8is servedCastilian (
es) instead of Catalan (ca).Root cause
starlette_babel.locale.LocaleFromHeader.__call__matches each headertoken against the supported set by full identifier only:
With supported locales
["ca","es","eu","nl","sv","en"], the top tokenca-ESnormalizes toca_es, which is not in the list, so it issilently dropped. Iteration continues to
es;q=0.9, an exact match, soeswins. 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
Note on
negotiate_localestarlette_babel.locale.negotiate_locale(babel-based) does not rescuethis either, because the parsed preferred tokens are lowercased with
hyphens (
ca-es) rather than babel'sca_ES, so babel's language-onlymatch never fires:
Expected
Each preferred token should fall back to its language-only subtag when
the full tag is unsupported, so
ca-ESmatches supportedca. Thehighest-ranked token with any (full or language-only) supported match
should win.
Workaround in use (downstream)
We registered a custom resolver via
register_locale_resolverthat doesregion-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.