Skip to content

fix: preserve shadow mode for limit overrides - #1274

Open
PatilHrushikesh wants to merge 2 commits into
envoyproxy:mainfrom
PatilHrushikesh:fix/dynamic-override-shadow-mode
Open

PatilHrushikesh wants to merge 2 commits into
envoyproxy:mainfrom
PatilHrushikesh:fix/dynamic-override-shadow-mode

Conversation

@PatilHrushikesh

@PatilHrushikesh PatilHrushikesh commented Oct 17, 2026 •

Copy link
Copy Markdown

Problem

When Envoy sends a per-request limit override, RLS returned a newly constructed limit with shadow_mode disabled. This caused dynamic FromMetadata quotas configured with shadowMode: true to return 429 instead of recording usage without enforcement.

The override path also replaced the original descriptor metric key with the matched configured descriptor key, which could collapse distinct descriptor values onto the same counter.

Fix

  • Match the configured descriptor without the per-request override.
  • Preserve configured properties such as shadow_mode, quota mode, and unlimited behavior.
  • Override only the effective request limit and unit.
  • Preserve the original full descriptor key for metrics and counters when overrides are used.
  • Retain fallback behavior for unmatched descriptors.

Validation

  • go test ./test/config
  • go test ./...

@PatilHrushikesh
PatilHrushikesh force-pushed the fix/dynamic-override-shadow-mode branch 3 times, most recently from 638da3e to daa86e0 Compare October 17, 2026 14:15
Signed-off-by: Hrushikesh Patil <hrushikesh.patil@nutanix.com>
Signed-off-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

@nacx nacx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

Comment thread src/config/config_impl.go
}

if limitOverride != nil {
rateLimit = this.applyLimitOverride(overrideKey, rateLimit, limitOverride)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now inherits every property of the matched descriptor, not only shadow_mode. The most important case is a descriptor matching unlimited: true (for example key6 in test/config/basic_config.yaml:63). constructLimitsToCheck (service/ratelimit.go:176-179) turns that into limitsToCheck[i] = nil, so the Envoy override is ignored and the request is never counted. Before this PR the override was enforced. Name/Replaces also now take part in the replacement pass at service/ratelimit.go:183-192, so an overridden descriptor can be dropped, or can drop another. QuotaMode changes the response aggregation. The PR body lists these as intended, but Envoy documents the override as replacing the configured limit, and existing users will see different enforcement with no release note. Please either (a) limit what carries over to ShadowMode (plus Metadata, if that's wanted), or (b) keep the full carry-over but add tests for the unlimited and replaces cases and a README/changelog note. If you keep (b), consider whether Unlimited should lose to an explicit override.

Comment thread src/config/config_impl.go Outdated
overrideKey = this.descriptorKey(domain, descriptor)
// Drop the per-request override for lookup so matched descriptor
// properties (e.g. shadow_mode) are preserved; reapply after matching.
descriptor = &pb_struct.RateLimitDescriptor{Entries: descriptor.Entries}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebinding the parameter doesn't touch the caller's request.Descriptors[i]. That matters because GenerateCacheKey (base_limiter.go:70) and GetHitsAddends read the original. The shared Entries slice is only read below.

But nothing after this line reads descriptor.Limit: the loop at 495 and the share_threshold builder at 642 use only Entries. The old code skipped matching because of its early return, not because the loop looked at the override. So this allocation, and the comment about "dropping the override for lookup", can go. Probably keeping overrideKey computed here is enough?

Comment thread src/config/config_impl.go
// The matched configuration supplies enforcement properties, but a fallback
// descriptor must not collapse distinct request values onto its default key.
rateLimit.FullKey = overrideKey
rateLimit.Stats = this.statsManager.NewStats(overrideKey)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This throws away the key work done before calling this method: the detailed_metric, value_to_metric and share_threshold paths (lines 633-678) have already called NewStats for their keys, and this replaces them. Two side effects. First, those earlier NewStats calls still register counters that are never incremented. Second, a share_threshold descriptor gets per-value stats while its Redis counter is still shared through ShareThresholdKeyPattern (cache_key.go:76-80). That matches the old override path's metric naming, so it isn't a regression, but it's worth one line in the comment above. Alternatively, short-circuit the metric-key rebuilding when limitOverride != nil.

Comment thread src/config/config_impl.go

rateLimit.Limit = &pb.RateLimitResponse_RateLimit{
RequestsPerUnit: override.GetRequestsPerUnit(),
Unit: unit,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewRateLimit sets Limit.Name, but this literal drops it, while rateLimit.Name keeps the matched name. If the name should survive the override, carry it into the response limit too.

		Unit:            unit,
		Name:            rateLimit.Limit.GetName(),

},
})
assert.NotNil(rl)
assert.True(rl.ShadowMode)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would fail against the old code, so it's a real regression test. Please also assert rl.FullKey / a stats counter here. The PR body's second claim (no collapse onto the default key) is otherwise covered only indirectly by TestConfigLimitOverride, which never matches a shadow/default leaf. Also add a case where the override hits an unlimited: true descriptor, so whichever behaviour you choose is pinned down.

@nacx nacx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! LGTM

@nacx

nacx commented Oct 21, 2026

Copy link
Copy Markdown
Member

Can you fix the DCO?

An Envoy override should replace the numeric limit and preserve shadow
mode, without inheriting unlimited/quota/name/replaces or rebuilding
metrics that are discarded for the override key.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Hrushikesh Patil <hrushikesh.patil@nutanix.com>
@PatilHrushikesh
PatilHrushikesh force-pushed the fix/dynamic-override-shadow-mode branch from 1e4aadc to 2c73c74 Compare October 21, 2026 08:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants