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

  1. Add a max_age field to the same RouteLookupConfig with a value greater than stale_age (e.g. "maxAge":"300s" paired with "staleAge":"240s").
  2. If you do not need background refresh, remove the stale_age field entirely so the config uses the default cache behavior.
  3. 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

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


AI-assisted analysis of grpc/grpc-go@03255a9237 (2026-08-07). Data as JSON: /api/errors/a737b84843617d82. Report an issue: GitHub.