Skip to content

Global rate-limit: cost (hits_addend) dropped for shared:true rules since 1.8.0 #9244

Description

@xiongzubiao

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

  1. 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
  1. Populate my.cost.ns/request_cost per request with a value > 1 (e.g. via an ext_proc or Lua filter).
  2. Send N requests and inspect the shared counter (Redis) and/or the rate-limit service at debug level.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind/bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions