Skip to content

Commit afec97a

Browse files
[Proposal] Share Threshold for Wildcard Rate Limiting (#1016)
* Add share_threshold to make wild card values can share rate limit threshold Signed-off-by: Nam Dang <xuannam230201@gmail.com> * Implement lazy initilization based on reviews Signed-off-by: Nam Dang <xuannam230201@gmail.com> --------- Signed-off-by: Nam Dang <xuannam230201@gmail.com>
1 parent 6b4f389 commit afec97a

9 files changed

Lines changed: 682 additions & 26 deletions

File tree

README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
- [ShadowMode](#shadowmode)
2323
- [Including detailed metrics for unspecified values](#including-detailed-metrics-for-unspecified-values)
2424
- [Including descriptor values in metrics](#including-descriptor-values-in-metrics)
25+
- [Sharing thresholds for wildcard matches](#sharing-thresholds-for-wildcard-matches)
2526
- [Examples](#examples)
2627
- [Example 1](#example-1)
2728
- [Example 2](#example-2)
@@ -33,6 +34,7 @@
3334
- [Example 8](#example-8)
3435
- [Example 9](#example-9)
3536
- [Example 10](#example-10)
37+
- [Example 11](#example-11)
3638
- [Loading Configuration](#loading-configuration)
3739
- [File Based Configuration Loading](#file-based-configuration-loading)
3840
- [xDS Management Server Based Configuration Loading](#xds-management-server-based-configuration-loading)
@@ -285,6 +287,7 @@ descriptors:
285287
shadow_mode: (optional)
286288
detailed_metric: (optional)
287289
value_to_metric: (optional)
290+
share_threshold: (optional)
288291
descriptors: (optional block)
289292
- ... (nested repetition of above)
290293
```
@@ -347,6 +350,20 @@ Setting `value_to_metric: true` (default: `false`) for a descriptor will include
347350

348351
When combined with wildcard matching, the full runtime value is included in the metric key, not just the wildcard prefix. This feature works independently of `detailed_metric` - when `detailed_metric` is set, it takes precedence and `value_to_metric` is ignored.
349352

353+
### Sharing thresholds for wildcard matches
354+
355+
Setting `share_threshold: true` (default: `false`) for a descriptor with a wildcard value (ending with `*`) allows all values matching that wildcard to share the same rate limit threshold, instead of using isolated thresholds for each matching value.
356+
357+
This is useful when you want to apply a single rate limit across multiple resources that match a wildcard pattern. For example, if you have a rule for `files/*`, both `files/a.pdf` and `files/b.csv` will share the same threshold when `share_threshold: true` is set.
358+
359+
**Important notes:**
360+
361+
- `share_threshold` can only be used with wildcard values (values ending with `*`)
362+
- When `share_threshold: true` is enabled, all matching values share the same cache key and rate limit counter
363+
- When `share_threshold: false` (or not set), each matching value has its own isolated threshold
364+
- When combined with `value_to_metric: true`, the metric key includes the wildcard prefix (the part before `*`) instead of the full runtime value, to reflect that values are sharing a threshold
365+
- When combined with `detailed_metric: true`, the metric key also includes the wildcard prefix for entries with `share_threshold` enabled
366+
350367
### Examples
351368

352369
#### Example 1
@@ -692,6 +709,58 @@ descriptors:
692709

693710
Note: When `detailed_metric: true` is set on a descriptor, it takes precedence and `value_to_metric` is ignored for that descriptor.
694711

712+
#### Example 11
713+
714+
Using `share_threshold: true` to share rate limits across wildcard matches:
715+
716+
```yaml
717+
domain: example11
718+
descriptors:
719+
# With share_threshold: true, all files/* matches share the same threshold
720+
- key: files
721+
value: files/*
722+
share_threshold: true
723+
rate_limit:
724+
unit: hour
725+
requests_per_unit: 10
726+
727+
# Without share_threshold, each files_no_share/* match has its own isolated threshold
728+
- key: files_no_share
729+
value: files_no_share/*
730+
share_threshold: false
731+
rate_limit:
732+
unit: hour
733+
requests_per_unit: 10
734+
```
735+
736+
With this configuration:
737+
738+
- Requests for `files/a.pdf`, `files/b.csv`, and `files/c.txt` all share the same threshold of 10 requests per hour
739+
- If 5 requests are made for `files/a.pdf` and 5 requests for `files/b.csv`, a request for `files/c.txt` will be rate limited (OVER_LIMIT) because the shared threshold of 10 has been reached
740+
- Requests for `files_no_share/a.pdf` and `files_no_share/b.csv` each have their own isolated threshold of 10 requests per hour
741+
- If 10 requests are made for `files_no_share/a.pdf` (exhausting its quota), requests for `files_no_share/b.csv` will still be allowed (up to 10 requests)
742+
743+
Combining `share_threshold` with `value_to_metric`:
744+
745+
```yaml
746+
domain: example11_metrics
747+
descriptors:
748+
- key: route
749+
value: api/*
750+
share_threshold: true
751+
value_to_metric: true
752+
descriptors:
753+
- key: method
754+
rate_limit:
755+
unit: minute
756+
requests_per_unit: 60
757+
```
758+
759+
- Request: `route=api/v1`, `method=GET`
760+
- Metric key: `example11_metrics.route_api.method_GET` (includes the wildcard prefix `api` instead of the full value `api/v1`)
761+
762+
This reflects that all `api/*` routes share the same threshold, while still providing visibility into which API routes are being accessed.
763+
695764
## Loading Configuration
696765

697766
Rate limit service supports following configuration loading methods. You can define which methods to use by configuring environment variable `CONFIG_TYPE`.

src/config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ type RateLimit struct {
2525
Name string
2626
Replaces []string
2727
DetailedMetric bool
28+
// ShareThresholdKeyPattern is a slice of wildcard patterns for descriptor entries
29+
// The slice index corresponds to the descriptor entry index.
30+
ShareThresholdKeyPattern []string
2831
}
2932

3033
// Interface for interacting with a loaded rate limit config.

src/config/config_impl.go

Lines changed: 154 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type YamlDescriptor struct {
3333
ShadowMode bool `yaml:"shadow_mode"`
3434
DetailedMetric bool `yaml:"detailed_metric"`
3535
ValueToMetric bool `yaml:"value_to_metric"`
36+
ShareThreshold bool `yaml:"share_threshold"`
3637
}
3738

3839
type YamlRoot struct {
@@ -41,10 +42,12 @@ type YamlRoot struct {
4142
}
4243

4344
type rateLimitDescriptor struct {
44-
descriptors map[string]*rateLimitDescriptor
45-
limit *RateLimit
46-
wildcardKeys []string
47-
valueToMetric bool
45+
descriptors map[string]*rateLimitDescriptor
46+
limit *RateLimit
47+
wildcardKeys []string
48+
valueToMetric bool
49+
shareThreshold bool
50+
wildcardPattern string // stores the wildcard pattern when share_threshold is true
4851
}
4952

5053
type rateLimitDomain struct {
@@ -71,6 +74,7 @@ var validKeys = map[string]bool{
7174
"replaces": true,
7275
"detailed_metric": true,
7376
"value_to_metric": true,
77+
"share_threshold": true,
7478
}
7579

7680
// Create a new rate limit config entry.
@@ -90,11 +94,12 @@ func NewRateLimit(requestsPerUnit uint32, unit pb.RateLimitResponse_RateLimit_Un
9094
Unit: unit,
9195
Name: name,
9296
},
93-
Unlimited: unlimited,
94-
ShadowMode: shadowMode,
95-
Name: name,
96-
Replaces: replaces,
97-
DetailedMetric: detailedMetric,
97+
Unlimited: unlimited,
98+
ShadowMode: shadowMode,
99+
Name: name,
100+
Replaces: replaces,
101+
DetailedMetric: detailedMetric,
102+
ShareThresholdKeyPattern: nil,
98103
}
99104
}
100105

@@ -186,16 +191,38 @@ func (this *rateLimitDescriptor) loadDescriptors(config RateLimitConfigToLoad, p
186191
}
187192
}
188193

189-
logger.Debugf(
190-
"loading descriptor: key=%s%s", newParentKey, rateLimitDebugString)
191-
newDescriptor := &rateLimitDescriptor{map[string]*rateLimitDescriptor{}, rateLimit, nil, descriptorConfig.ValueToMetric}
192-
newDescriptor.loadDescriptors(config, newParentKey+".", descriptorConfig.Descriptors, statsManager)
193-
this.descriptors[finalKey] = newDescriptor
194+
// Validate share_threshold can only be used with wildcards
195+
if descriptorConfig.ShareThreshold {
196+
if len(finalKey) == 0 || finalKey[len(finalKey)-1:] != "*" {
197+
panic(newRateLimitConfigError(
198+
config.Name,
199+
fmt.Sprintf("share_threshold can only be used with wildcard values (ending with '*'), but found key '%s'", finalKey)))
200+
}
201+
}
202+
203+
// Store wildcard pattern if share_threshold is enabled
204+
var wildcardPattern string = ""
205+
if descriptorConfig.ShareThreshold && len(finalKey) > 0 && finalKey[len(finalKey)-1:] == "*" {
206+
wildcardPattern = finalKey
207+
}
194208

195209
// Preload keys ending with "*" symbol.
196210
if finalKey[len(finalKey)-1:] == "*" {
197211
this.wildcardKeys = append(this.wildcardKeys, finalKey)
198212
}
213+
214+
logger.Debugf(
215+
"loading descriptor: key=%s%s", newParentKey, rateLimitDebugString)
216+
newDescriptor := &rateLimitDescriptor{
217+
descriptors: map[string]*rateLimitDescriptor{},
218+
limit: rateLimit,
219+
wildcardKeys: nil,
220+
valueToMetric: descriptorConfig.ValueToMetric,
221+
shareThreshold: descriptorConfig.ShareThreshold,
222+
wildcardPattern: wildcardPattern,
223+
}
224+
newDescriptor.loadDescriptors(config, newParentKey+".", descriptorConfig.Descriptors, statsManager)
225+
this.descriptors[finalKey] = newDescriptor
199226
}
200227
}
201228

@@ -265,7 +292,14 @@ func (this *rateLimitConfigImpl) loadConfig(config RateLimitConfigToLoad) {
265292
}
266293

267294
logger.Debugf("loading domain: %s", root.Domain)
268-
newDomain := &rateLimitDomain{rateLimitDescriptor{map[string]*rateLimitDescriptor{}, nil, nil, false}}
295+
newDomain := &rateLimitDomain{rateLimitDescriptor{
296+
descriptors: map[string]*rateLimitDescriptor{},
297+
limit: nil,
298+
wildcardKeys: nil,
299+
valueToMetric: false,
300+
shareThreshold: false,
301+
wildcardPattern: "",
302+
}}
269303
newDomain.loadDescriptors(config, root.Domain+".", root.Descriptors, this.statsManager)
270304
this.domains[root.Domain] = newDomain
271305
}
@@ -320,6 +354,10 @@ func (this *rateLimitConfigImpl) GetLimit(
320354
var valueToMetricFullKey strings.Builder
321355
valueToMetricFullKey.WriteString(domain)
322356

357+
// Track share_threshold patterns for entries matched via wildcard (using indexes)
358+
// This allows share_threshold to work when wildcard has nested descriptors
359+
var shareThresholdPatterns map[int]string
360+
323361
for i, entry := range descriptor.Entries {
324362
// First see if key_value is in the map. If that isn't in the map we look for just key
325363
// to check for a default value.
@@ -350,11 +388,31 @@ func (this *rateLimitConfigImpl) GetLimit(
350388
matchedUsingValue = false
351389
}
352390

391+
// Track share_threshold pattern when matching via wildcard, even if no rate_limit at this level
392+
if matchedViaWildcard && nextDescriptor != nil && nextDescriptor.shareThreshold && nextDescriptor.wildcardPattern != "" {
393+
// Extract the value part from the wildcard pattern (e.g., "key_files*" -> "files*")
394+
if shareThresholdPatterns == nil {
395+
shareThresholdPatterns = make(map[int]string)
396+
}
397+
398+
wildcardValue := strings.TrimPrefix(nextDescriptor.wildcardPattern, entry.Key+"_")
399+
shareThresholdPatterns[i] = wildcardValue
400+
logger.Debugf("tracking share_threshold for entry index %d (key %s), wildcard pattern %s", i, entry.Key, wildcardValue)
401+
}
402+
353403
// Build value_to_metric metrics path for this level
354404
valueToMetricFullKey.WriteString(".")
355405
if nextDescriptor != nil {
406+
// Check if share_threshold is enabled for this entry
407+
hasShareThreshold := shareThresholdPatterns[i] != ""
356408
if matchedViaWildcard {
357-
if nextDescriptor.valueToMetric {
409+
// When share_threshold is enabled AND value_to_metric is enabled, use the prefix of the wildcard pattern
410+
if hasShareThreshold && nextDescriptor.valueToMetric {
411+
wildcardPrefix := strings.TrimSuffix(shareThresholdPatterns[i], "*")
412+
valueToMetricFullKey.WriteString(entry.Key)
413+
valueToMetricFullKey.WriteString("_")
414+
valueToMetricFullKey.WriteString(wildcardPrefix)
415+
} else if nextDescriptor.valueToMetric {
358416
valueToMetricFullKey.WriteString(entry.Key)
359417
if entry.Value != "" {
360418
valueToMetricFullKey.WriteString("_")
@@ -365,14 +423,28 @@ func (this *rateLimitConfigImpl) GetLimit(
365423
}
366424
} else if matchedUsingValue {
367425
// Matched explicit key+value in config
368-
valueToMetricFullKey.WriteString(entry.Key)
369-
if entry.Value != "" {
426+
// When share_threshold is enabled AND value_to_metric is enabled, use the prefix of the wildcard pattern
427+
if hasShareThreshold && nextDescriptor.valueToMetric {
428+
wildcardPrefix := strings.TrimSuffix(shareThresholdPatterns[i], "*")
429+
valueToMetricFullKey.WriteString(entry.Key)
370430
valueToMetricFullKey.WriteString("_")
371-
valueToMetricFullKey.WriteString(entry.Value)
431+
valueToMetricFullKey.WriteString(wildcardPrefix)
432+
} else {
433+
valueToMetricFullKey.WriteString(entry.Key)
434+
if entry.Value != "" {
435+
valueToMetricFullKey.WriteString("_")
436+
valueToMetricFullKey.WriteString(entry.Value)
437+
}
372438
}
373439
} else {
374440
// Matched default key (no value) in config
375-
if nextDescriptor.valueToMetric {
441+
// When share_threshold is enabled AND value_to_metric is enabled, use the prefix of the wildcard pattern
442+
if hasShareThreshold && nextDescriptor.valueToMetric {
443+
wildcardPrefix := strings.TrimSuffix(shareThresholdPatterns[i], "*")
444+
valueToMetricFullKey.WriteString(entry.Key)
445+
valueToMetricFullKey.WriteString("_")
446+
valueToMetricFullKey.WriteString(wildcardPrefix)
447+
} else if nextDescriptor.valueToMetric {
376448
valueToMetricFullKey.WriteString(entry.Key)
377449
if entry.Value != "" {
378450
valueToMetricFullKey.WriteString("_")
@@ -391,7 +463,31 @@ func (this *rateLimitConfigImpl) GetLimit(
391463
logger.Debugf("found rate limit: %s", finalKey)
392464

393465
if i == len(descriptor.Entries)-1 {
394-
rateLimit = nextDescriptor.limit
466+
// Create a copy of the rate limit to avoid modifying the shared object
467+
originalLimit := nextDescriptor.limit
468+
rateLimit = &RateLimit{
469+
FullKey: originalLimit.FullKey,
470+
Stats: originalLimit.Stats,
471+
Limit: originalLimit.Limit,
472+
Unlimited: originalLimit.Unlimited,
473+
ShadowMode: originalLimit.ShadowMode,
474+
Name: originalLimit.Name,
475+
Replaces: originalLimit.Replaces,
476+
DetailedMetric: originalLimit.DetailedMetric,
477+
// Initialize ShareThresholdKeyPattern with correct length, empty strings for entries without share_threshold
478+
ShareThresholdKeyPattern: nil,
479+
}
480+
// Apply all tracked share_threshold patterns when we find the rate_limit
481+
// This works whether the rate_limit is at the wildcard level or deeper
482+
// Only entries with share_threshold will have non-empty patterns
483+
if len(shareThresholdPatterns) > 0 {
484+
rateLimit.ShareThresholdKeyPattern = make([]string, len(descriptor.Entries))
485+
}
486+
487+
for idx, pattern := range shareThresholdPatterns {
488+
rateLimit.ShareThresholdKeyPattern[idx] = pattern
489+
logger.Debugf("share_threshold enabled for entry index %d, using wildcard pattern %s", idx, pattern)
490+
}
395491
} else {
396492
logger.Debugf("request depth does not match config depth, there are more entries in the request's descriptor")
397493
}
@@ -402,7 +498,10 @@ func (this *rateLimitConfigImpl) GetLimit(
402498
descriptorsMap = nextDescriptor.descriptors
403499
} else {
404500
if rateLimit != nil && rateLimit.DetailedMetric {
501+
// Preserve ShareThresholdKeyPattern when recreating rate limit
502+
originalShareThresholdKeyPattern := rateLimit.ShareThresholdKeyPattern
405503
rateLimit = NewRateLimit(rateLimit.Limit.RequestsPerUnit, rateLimit.Limit.Unit, this.statsManager.NewStats(rateLimit.FullKey), rateLimit.Unlimited, rateLimit.ShadowMode, rateLimit.Name, rateLimit.Replaces, rateLimit.DetailedMetric)
504+
rateLimit.ShareThresholdKeyPattern = originalShareThresholdKeyPattern
406505
}
407506

408507
break
@@ -411,10 +510,39 @@ func (this *rateLimitConfigImpl) GetLimit(
411510
}
412511

413512
// Replace metric with detailed metric, if leaf descriptor is detailed.
513+
// When share_threshold is enabled, expose the prefix (before *) of the wildcard pattern
414514
if rateLimit != nil && rateLimit.DetailedMetric {
415-
detailedKey := detailedMetricFullKey.String()
416-
rateLimit.Stats = this.statsManager.NewStats(detailedKey)
417-
rateLimit.FullKey = detailedKey
515+
// Check if any entry has share_threshold enabled
516+
hasShareThreshold := rateLimit.ShareThresholdKeyPattern != nil && len(rateLimit.ShareThresholdKeyPattern) > 0
517+
if hasShareThreshold {
518+
// Build metric key with wildcard prefix for entries with share_threshold
519+
var shareThresholdMetricKey strings.Builder
520+
shareThresholdMetricKey.WriteString(domain)
521+
for i, entry := range descriptor.Entries {
522+
shareThresholdMetricKey.WriteString(".")
523+
if i < len(rateLimit.ShareThresholdKeyPattern) && rateLimit.ShareThresholdKeyPattern[i] != "" {
524+
// Use the prefix of the wildcard pattern (before *)
525+
wildcardPrefix := strings.TrimSuffix(rateLimit.ShareThresholdKeyPattern[i], "*")
526+
shareThresholdMetricKey.WriteString(entry.Key)
527+
shareThresholdMetricKey.WriteString("_")
528+
shareThresholdMetricKey.WriteString(wildcardPrefix)
529+
} else {
530+
// Include full key_value for entries without share_threshold
531+
shareThresholdMetricKey.WriteString(entry.Key)
532+
if entry.Value != "" {
533+
shareThresholdMetricKey.WriteString("_")
534+
shareThresholdMetricKey.WriteString(entry.Value)
535+
}
536+
}
537+
}
538+
shareThresholdKey := shareThresholdMetricKey.String()
539+
rateLimit.FullKey = shareThresholdKey
540+
rateLimit.Stats = this.statsManager.NewStats(shareThresholdKey)
541+
} else {
542+
detailedKey := detailedMetricFullKey.String()
543+
rateLimit.FullKey = detailedKey
544+
rateLimit.Stats = this.statsManager.NewStats(detailedKey)
545+
}
418546
}
419547

420548
// If not using detailed metric, but any value_to_metric path produced a different key,
@@ -423,7 +551,9 @@ func (this *rateLimitConfigImpl) GetLimit(
423551
enhancedKey := valueToMetricFullKey.String()
424552
if enhancedKey != rateLimit.FullKey {
425553
// Recreate to ensure a clean stats struct, then set to enhanced stats
554+
originalShareThresholdKeyPattern := rateLimit.ShareThresholdKeyPattern
426555
rateLimit = NewRateLimit(rateLimit.Limit.RequestsPerUnit, rateLimit.Limit.Unit, this.statsManager.NewStats(rateLimit.FullKey), rateLimit.Unlimited, rateLimit.ShadowMode, rateLimit.Name, rateLimit.Replaces, rateLimit.DetailedMetric)
556+
rateLimit.ShareThresholdKeyPattern = originalShareThresholdKeyPattern
427557
rateLimit.Stats = this.statsManager.NewStats(enhancedKey)
428558
rateLimit.FullKey = enhancedKey
429559
}

0 commit comments

Comments
 (0)