grpc/grpc-go · error
rls: stale_age is set, but max_age is not in route lookup co
Error message
rls: stale_age is set, but max_age is not in route lookup config %+v
What it means
The RLS data cache uses two timers: max_age (hard expiry, evicts the entry) and stale_age (soft expiry, triggers a background refresh while still serving the stale entry). Per the RLS LB policy design, stale_age is only meaningful relative to max_age, so gRPC refuses a RouteLookupConfig that sets stale_age without also setting max_age. The check happens at service-config parse time in parseRLSProto.
Source
Thrown at balancer/rls/config.go:249
if maxAge == 0 {
maxAge = maxMaxAge
} else {
maxAgeSet = true
}
staleAgeSet := false
staleAge, err := convertDuration(rlsProto.GetStaleAge())
if err != nil {
return nil, fmt.Errorf("rls: failed to parse staleAge in route lookup config %+v: %v", rlsProto, err)
}
if staleAge == 0 {
staleAge = maxMaxAge
} else {
staleAgeSet = true
}
if staleAgeSet && !maxAgeSet {
return nil, fmt.Errorf("rls: stale_age is set, but max_age is not in route lookup config %+v", rlsProto)
}
if staleAge > maxMaxAge {
staleAge = maxMaxAge
}
if !staleAgeSet && maxAge > maxMaxAge {
maxAge = maxMaxAge
}
if staleAge > maxAge {
staleAge = maxAge
}
// `cache_size_bytes` field must have a value greater than 0, and if its
// value is greater than 5M, we cap it at 5M
cacheSizeBytes := rlsProto.GetCacheSizeBytes()
if cacheSizeBytes <= 0 {
return nil, fmt.Errorf("rls: cache_size_bytes must be set to a non-zero value: %+v", rlsProto)
}
if cacheSizeBytes > maxCacheSize {View on GitHub (pinned to 03255a9237)
Solutions
- Add a max_age field to the same RouteLookupConfig with a value greater than stale_age (e.g. "maxAge":"300s" paired with "staleAge":"240s").
- If you do not need background refresh, remove the stale_age field entirely so the config uses the default cache behavior.
- Remember max_age and stale_age are both capped at 5 minutes; values above 5m are silently clamped, so keep max_age <= 300s.
Example fix
// before (service config JSON fragment)
"routeLookupConfig": {
"lookupService": "rls.example:443",
"staleAge": "240s",
"cacheSizeBytes": 1000000
}
// after
"routeLookupConfig": {
"lookupService": "rls.example:443",
"maxAge": "300s",
"staleAge": "240s",
"cacheSizeBytes": 1000000
} Defensive patterns
Strategy: validation
Validate before calling
func validateRLSAges(cfg *rlspb.RouteLookupConfig) error {
maxSet := cfg.GetMaxAge() != nil && cfg.GetMaxAge().AsDuration() != 0
staleSet := cfg.GetStaleAge() != nil && cfg.GetStaleAge().AsDuration() != 0
if staleSet && !maxSet {
return fmt.Errorf("stale_age set without max_age")
}
if maxSet && cfg.GetMaxAge().AsDuration() > 5*time.Minute {
fmt.Println("note: max_age > 5m will be clamped to 5m")
}
return nil
} Prevention
- Always set maxAge and staleAge together in templated RLS configs.
- Keep a JSON schema for your RLS service config and require maxAge whenever staleAge is present.
- Run the config through rls.ParseConfig (or an equivalent validator) in CI before deploying.
When it happens
Trigger: A RouteLookupConfig where max_age is unset/zero but stale_age is a non-zero duration. Produced by passing a service config JSON like {"routeLookupConfig":{..."staleAge":"30s"...}} with no "maxAge" field, or a proto where GetMaxAge() is nil/0s and GetStaleAge() is set.
Common situations: Copying a partial RLS example that omits max_age; templated configs that conditionally inject stale_age but forget max_age; assuming max_age has a useful default when stale_age is supplied (it does default to 5m, but only when stale_age is also unset).
Related errors
- rls: bad control channel service config %q: %v
- rls: cache_size_bytes must be set to a non-zero value: %+v
- rls: invalid childPolicy: entry %v does not contain exactly
- rls: childPolicy %q with config %q does not support config p
- rls: json unmarshal failed for child policy config %q: %v
AI-assisted analysis of grpc/grpc-go@03255a9237 (2026-08-07).
Data as JSON: /api/errors/a737b84843617d82.
Report an issue: GitHub.