Skip to content

Caching per language

Goal: a product catalog answers in the caller’s language, and every caller who reads German shares one cached German answer, whatever their browser puts in Accept-Language.

Partitioning on the raw header does not get you there. de-DE,de;q=0.9,en;q=0.8, de-AT and de are three different strings, so they would be three cache entries holding the same German text, and the number of entries grows with every browser and every language setting in the wild. A vary rule can reduce the header to the one part your origin actually uses.

The running example is shop-api, whose origin translates product names and category names. It supports English and German and answers English for anything else.

  • A deployed service and its gpilot.toml.
  • An origin that can read the language from a header GraphPilot sets, here x-locale.
gpilot.toml
[[cache.policy.vary]]
name = "x-locale"
value = "lower(split(split(split(header:accept-language, ',', 0), ';', 0), '-', 0)) || 'en'"
pass_to_origin = "plain"

Read the expression from the inside out, for de-AT,de;q=0.9,en;q=0.8:

StepResult
split(header:accept-language, ',', 0): the first language rangede-AT
split(..., ';', 0): without a ;q= weight, if the first range has onede-AT
split(..., '-', 0): the primary language, without the regionde-AT becomes de
lower(...): one spelling for DE and dede
|| 'en': the fallback when there is no headeren

pass_to_origin = "plain" sends the result to your origin as x-locale: de. Answer from that header, not from Accept-Language: the cache entry is keyed on x-locale, so an answer that depended on anything else in Accept-Language would be served to callers it was not meant for.

The rule has no scope_only, so it partitions every cached response of the service, which is what a translated catalog needs. Configuring it also claims the name: an x-locale header a client sends itself is removed on the way in.

Deploy with gpilot deploy, then ask for the same product with two different German browser settings:

Terminal window
for lang in 'de-DE,de;q=0.9,en;q=0.8' 'de-AT'; do
curl -sS -D- -o /dev/null "https://$GRAPHPILOT_HOST/graphql" \
-H 'content-type: application/json' \
-H "accept-language: $lang" \
-d '{"query":"query ProductName { product(id: \"p01\") { id name } }"}' \
| grep -i '^gp-cache:'
done
gp-cache: MISS
gp-cache: HIT

Both derive de, so the second request is served from the entry the first one stored. A third request with accept-language: en-US derives en and is a MISS of its own.

Open one of the requests in Insights. The Cache tab shows Segmentation Vary, split by x-locale. By default the value is stored as a keyed pseudonym (hmac-sha256: and 16 hex digits), marked pseudonymized, so the two German requests show the same pseudonym and the English one another. Requests in this bucket lists every request that read the German entry.

A language is not personal data, so you may prefer to read de in clear. That is telemetry.vary_values = "clear", and it applies to every vary rule of the service: switch it only if every other rule’s value is fine to store in clear.

  • Every language a client sends is its own entry. The rule collapses regions and weights, not languages: a caller sending xx gets an xx entry, answered in English by your origin. That stays bounded by the languages clients send, which is far fewer than the header strings. If you need a fixed set, let your client send x-app-locale from a list it controls and vary on lower(header:x-app-locale) || 'en' instead.
  • Only the first language range counts. fr-CH,de;q=0.9 derives fr, even though your origin only speaks German and English. That is the right entry as long as your origin answers fr with its fallback.
  • The rule splits everything. Responses that are the same in every language, such as prices served by a separate query, are stored once per language too. Keep untranslated data in its own operations if the hit rate matters.
  • Only two languages? A public predicate such as starts_with(lower(header:accept-language), 'de') gives exactly two entries. Its value is not sent to your origin, so your origin has to pick the language from Accept-Language the same way; see public.
  • A gp- name does not work. GraphPilot removes gp- headers before your origin, so pass_to_origin could never deliver it. Use an x- name.