{"record":{"id":"a737b84843617d82","repo":"grpc/grpc-go","slug":"rls-stale-age-is-set-but-max-age-is-not-in-route","errorCode":null,"errorMessage":"rls: stale_age is set, but max_age is not in route lookup config %+v","messagePattern":"rls: stale_age is set, but max_age is not in route lookup config %\\+v","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"balancer/rls/config.go","lineNumber":249,"sourceCode":"\tif maxAge == 0 {\n\t\tmaxAge = maxMaxAge\n\t} else {\n\t\tmaxAgeSet = true\n\t}\n\n\tstaleAgeSet := false\n\tstaleAge, err := convertDuration(rlsProto.GetStaleAge())\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"rls: failed to parse staleAge in route lookup config %+v: %v\", rlsProto, err)\n\t}\n\tif staleAge == 0 {\n\t\tstaleAge = maxMaxAge\n\t} else {\n\t\tstaleAgeSet = true\n\t}\n\n\tif staleAgeSet && !maxAgeSet {\n\t\treturn nil, fmt.Errorf(\"rls: stale_age is set, but max_age is not in route lookup config %+v\", rlsProto)\n\t}\n\tif staleAge > maxMaxAge {\n\t\tstaleAge = maxMaxAge\n\t}\n\tif !staleAgeSet && maxAge > maxMaxAge {\n\t\tmaxAge = maxMaxAge\n\t}\n\tif staleAge > maxAge {\n\t\tstaleAge = maxAge\n\t}\n\n\t// `cache_size_bytes` field must have a value greater than 0, and if its\n\t// value is greater than 5M, we cap it at 5M\n\tcacheSizeBytes := rlsProto.GetCacheSizeBytes()\n\tif cacheSizeBytes <= 0 {\n\t\treturn nil, fmt.Errorf(\"rls: cache_size_bytes must be set to a non-zero value: %+v\", rlsProto)\n\t}\n\tif cacheSizeBytes > maxCacheSize {","sourceCodeStart":231,"sourceCodeEnd":267,"githubUrl":"https://github.com/grpc/grpc-go/blob/0c51461d27177d997e14c642fe18c11668fc09a3/balancer/rls/config.go#L231-L267","documentation":"Emitted from parseRLSProto (config.go:249) when stale_age is explicitly set (non-zero) in the RouteLookupConfig but max_age is not. Background refresh (stale) semantics require a hard expiry (max) to be defined, so the config is rejected. Zero stale_age skips this check.","triggerScenarios":"routeLookupConfig provides staleAge (non-zero) and omits maxAge or sets it to zero.","commonSituations":"An operator sets stale_age for background refresh but assumes max_age has a default; xDS RLS resource that only partially populates the duration fields; a config-generation bug that writes stale_age conditionally but max_age unconditionally-as-zero.","solutions":["Set max_age whenever you set stale_age, with stale_age <= max_age (e.g. max_age 300s, stale_age 240s).","If you do not need background refresh, omit stale_age (or set it to zero) and rely on max_age alone.","Fix the control-plane template so the two duration fields are emitted as a pair."],"exampleFix":"// before\n\"routeLookupConfig\": { \"maxAge\": null, \"staleAge\": { \"seconds\": 240 }, ... }\n\n// after\n\"routeLookupConfig\": { \"maxAge\": { \"seconds\": 300 }, \"staleAge\": { \"seconds\": 240 }, ... }","handlingStrategy":"validation","validationCode":"func validateAgePair(maxAge, staleAge *durationpb.Duration) error {\n    if staleAge == nil {\n        return nil\n    }\n    if staleAge.AsDuration() != 0 && (maxAge == nil || maxAge.AsDuration() == 0) {\n        return errors.New(\"stale_age is set but max_age is not; provide a non-zero max_age\")\n    }\n    return nil\n}","typeGuard":"func agePairIsConsistent(maxAge, staleAge *durationpb.Duration) bool {\n    if staleAge == nil || staleAge.AsDuration() == 0 {\n        return true\n    }\n    return maxAge != nil && maxAge.AsDuration() != 0\n}","tryCatchPattern":null,"preventionTips":["Treat max_age and stale_age as a pair in config templates and schema.","Default stale_age to unset unless background refresh is explicitly wanted.","Add a config-lint rule that rejects stale_age-without-max_age in CI."],"tags":["rls","duration","validation","service-config"],"backgroundTag":null,"analyzedSha":"0c51461d27177d997e14c642fe18c11668fc09a3","analyzedAt":"2026-08-11T14:49:15.055Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}