Description
On Envoy Gateway 1.8.x, a BackendTrafficPolicy global rate-limit rule that is both shared: true and carries a cost (e.g. cost.response.from: Metadata) no longer applies the per-request cost. The shared counter increments by the rate-limit service's default (≈1 per descriptor application) on every request regardless of the metadata value, so a cost/budget-based limit is effectively never reached.
This worked on 1.7.x. The cost metadata is computed and present in dynamic metadata, and the generated route.rate_limits entry even contains the hits_addend (apply_on_stream_done: true) — but at runtime the rate-limit service receives the descriptor with no hits_addend, so it applies its default.
Root cause
PR #8741 ("fix: use per-route ratelimit filter", first released in 1.8.0) changed the routing predicate in internal/xds/translator/ratelimit.go (patchRouteWithRateLimit / buildRouteRateLimits):
- 1.7.x:
if costSpecified { return patchRouteWithRateLimitOnTypedFilterConfig(...) } — cost-bearing rules are emitted on the per-route RateLimitPerRoute typed_per_filter_config, where hits_addend is honored.
- 1.8.x: the predicate became
if hasSharedRule { xdsRouteAction.RateLimits = rateLimits; return } — shared rules are emitted on route.rate_limits.
A rule that is both shared and cost-bearing now lands on route.rate_limits. hits_addend is still set on the RateLimit proto (and shows up in the config dump), but Envoy's route-level rate-limit path (Router::RateLimitPolicyEntryImpl) does not apply it — in particular the apply_on_stream_done response cost — so the cost is dropped. Non-shared cost rules are unaffected (they still take the filter path).
Steps to reproduce
- On EG 1.8.1, apply a Global rate-limit
BackendTrafficPolicy whose rule has a clientSelector, shared: true, and a response cost read from dynamic metadata:
rateLimit:
type: Global
global:
rules:
- clientSelectors:
- headers:
- name: x-user-id
type: Distinct
limit:
requests: 1000 # treated as a cost budget
unit: Hour
shared: true
cost:
request:
from: Number
number: 0
response:
from: Metadata
metadata:
namespace: my.cost.ns
key: request_cost
- Populate
my.cost.ns/request_cost per request with a value > 1 (e.g. via an ext_proc or Lua filter).
- Send N requests and inspect the shared counter (Redis) and/or the rate-limit service at debug level.
- Observe: the counter rises by ≈1 per request instead of by
request_cost; ShouldRateLimit arrives with no hits_addend, and the budget is never reached.
xDS-only check (no traffic needed): render the same rule with shared: true vs without and diff the config dump. Shared → the rule (with its hits_addend) appears under the route's route.rate_limits. Non-shared → it appears under the envoy.filters.http.ratelimit per-route config.
Expected
A shared, cost-based rule charges the per-request cost to the shared counter (counter += request_cost) — same as 1.7.x and same as a non-shared cost rule.
Actual
counter += ≈1 per request; the cost is ignored. Any cost/budget cap built on a shared: true cost rule is silently unenforced.
Environment
- Broken: Envoy Gateway 1.8.0 and 1.8.1 (Envoy 1.38.1)
- Works: Envoy Gateway 1.7.x (Envoy 1.37.x)
- Confirmed: pinning the controller back to 1.7.4 with no other change (same CRDs, same
BackendTrafficPolicy, same metadata-producing filter) restores proportional charging and the cap. Verified end-to-end against a passing rate-limit e2e suite.
Suggested direction
When a rule is both shared and cost-bearing, route it to a path that honors hits_addend. The per-route RateLimitPerRoute filter already does — so either keep cost rules on the filter path even when shared, or make the shared route.rate_limits path apply the hits_addend (including apply_on_stream_done).
Description
On Envoy Gateway 1.8.x, a
BackendTrafficPolicyglobal rate-limit rule that is bothshared: trueand carries acost(e.g.cost.response.from: Metadata) no longer applies the per-request cost. The shared counter increments by the rate-limit service's default (≈1 per descriptor application) on every request regardless of the metadata value, so a cost/budget-based limit is effectively never reached.This worked on 1.7.x. The cost metadata is computed and present in dynamic metadata, and the generated
route.rate_limitsentry even contains thehits_addend(apply_on_stream_done: true) — but at runtime the rate-limit service receives the descriptor with nohits_addend, so it applies its default.Root cause
PR #8741 ("fix: use per-route ratelimit filter", first released in 1.8.0) changed the routing predicate in
internal/xds/translator/ratelimit.go(patchRouteWithRateLimit/buildRouteRateLimits):if costSpecified { return patchRouteWithRateLimitOnTypedFilterConfig(...) }— cost-bearing rules are emitted on the per-routeRateLimitPerRoutetyped_per_filter_config, wherehits_addendis honored.if hasSharedRule { xdsRouteAction.RateLimits = rateLimits; return }— shared rules are emitted onroute.rate_limits.A rule that is both shared and cost-bearing now lands on
route.rate_limits.hits_addendis still set on theRateLimitproto (and shows up in the config dump), but Envoy's route-level rate-limit path (Router::RateLimitPolicyEntryImpl) does not apply it — in particular theapply_on_stream_doneresponse cost — so the cost is dropped. Non-shared cost rules are unaffected (they still take the filter path).Steps to reproduce
BackendTrafficPolicywhose rule has aclientSelector,shared: true, and a response cost read from dynamic metadata:my.cost.ns/request_costper request with a value > 1 (e.g. via an ext_proc or Lua filter).request_cost;ShouldRateLimitarrives with nohits_addend, and the budget is never reached.xDS-only check (no traffic needed): render the same rule with
shared: truevs without and diff the config dump. Shared → the rule (with itshits_addend) appears under the route'sroute.rate_limits. Non-shared → it appears under theenvoy.filters.http.ratelimitper-route config.Expected
A shared, cost-based rule charges the per-request cost to the shared counter (
counter += request_cost) — same as 1.7.x and same as a non-shared cost rule.Actual
counter += ≈1per request; the cost is ignored. Any cost/budget cap built on ashared: truecost rule is silently unenforced.Environment
BackendTrafficPolicy, same metadata-producing filter) restores proportional charging and the cap. Verified end-to-end against a passing rate-limit e2e suite.Suggested direction
When a rule is both
sharedand cost-bearing, route it to a path that honorshits_addend. The per-routeRateLimitPerRoutefilter already does — so either keep cost rules on the filter path even when shared, or make the sharedroute.rate_limitspath apply thehits_addend(includingapply_on_stream_done).