Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add unit multiplier #552

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ descriptors:
- name: (optional)
unit: <see below: required>
requests_per_unit: <see below: required>
unit_multiplier: <see below: optional>
shadow_mode: (optional)
detailed_metric: (optional)
descriptors: (optional block)
Expand All @@ -268,11 +269,15 @@ effectively whitelisted. Otherwise, nested descriptors allow more complex matchi
rate_limit:
unit: <second, minute, hour, day>
requests_per_unit: <uint>
unit_multiplier: <uint>
```

The rate limit block specifies the actual rate limit that will be used when there is a match.
Currently the service supports per second, minute, hour, and day limits. More types of limits may be added in the
future based on user demand.
The `unit_multiplier` allows for creating custom rate limit durations in combination with `unit`.
This allows for rate limit durations such as 30 seconds or 5 minutes.
A `unit_multiplier` of 0 is invalid and leaving out the field means the duration is equal to the unit (e.g. 1 minute).

### Replaces

Expand Down
4 changes: 4 additions & 0 deletions api/ratelimit/config/ratelimit/v3/rls_conf.proto
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ message RateLimitPolicy {
// For more information: https://github.com/envoyproxy/ratelimit/tree/0b2f4d5fb04bf55e1873e2c5e2bb28da67c0643f#replaces
// Example: https://github.com/envoyproxy/ratelimit/tree/0b2f4d5fb04bf55e1873e2c5e2bb28da67c0643f#example-7
repeated RateLimitReplace replaces = 5;

// Multiplier for the unit of time for the rate limit.
// Used to create custom periods e.g. 10 seconds or 5 minutes.
optional uint32 unit_multiplier = 6;
}

// Replace specifies the rate limit policy that should be replaced (dropped evaluation).
Expand Down
36 changes: 29 additions & 7 deletions src/config/config_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ type yamlReplaces struct {
type YamlRateLimit struct {
RequestsPerUnit uint32 `yaml:"requests_per_unit"`
Unit string
Unlimited bool `yaml:"unlimited"`
UnitMultiplier *uint32 `yaml:"unit_multiplier"`
Unlimited bool `yaml:"unlimited"`
Name string
Replaces []yamlReplaces
}
Expand Down Expand Up @@ -68,16 +69,18 @@ var validKeys = map[string]bool{
"name": true,
"replaces": true,
"detailed_metric": true,
"unit_multiplier": true,
}

// Create a new rate limit config entry.
// @param requestsPerUnit supplies the requests per unit of time for the entry.
// @param unit supplies the unit of time for the entry.
// @param unitMultiplier supplies the multiplier for the unit of time for the entry.
// @param rlStats supplies the stats structure associated with the RateLimit
// @param unlimited supplies whether the rate limit is unlimited
// @return the new config entry.
func NewRateLimit(requestsPerUnit uint32, unit pb.RateLimitResponse_RateLimit_Unit, rlStats stats.RateLimitStats,
unlimited bool, shadowMode bool, name string, replaces []string, detailedMetric bool,
unlimited bool, shadowMode bool, name string, replaces []string, detailedMetric bool, unitMultiplier uint32,
) *RateLimit {
return &RateLimit{
FullKey: rlStats.GetKey(),
Expand All @@ -86,6 +89,7 @@ func NewRateLimit(requestsPerUnit uint32, unit pb.RateLimitResponse_RateLimit_Un
RequestsPerUnit: requestsPerUnit,
Unit: unit,
Name: name,
UnitMultiplier: unitMultiplier,
},
Unlimited: unlimited,
ShadowMode: shadowMode,
Expand All @@ -100,8 +104,8 @@ func (this *rateLimitDescriptor) dump() string {
ret := ""
if this.limit != nil {
ret += fmt.Sprintf(
"%s: unit=%s requests_per_unit=%d, shadow_mode: %t\n", this.limit.FullKey,
this.limit.Limit.Unit.String(), this.limit.Limit.RequestsPerUnit, this.limit.ShadowMode)
"%s: unit=%s, unit_multiplier=%d, requests_per_unit=%d, shadow_mode: %t\n", this.limit.FullKey,
this.limit.Limit.Unit.String(), this.limit.Limit.UnitMultiplier, this.limit.Limit.RequestsPerUnit, this.limit.ShadowMode)
}
for _, descriptor := range this.descriptors {
ret += descriptor.dump()
Expand Down Expand Up @@ -159,6 +163,18 @@ func (this *rateLimitDescriptor) loadDescriptors(config RateLimitConfigToLoad, p
fmt.Sprintf("invalid rate limit unit '%s'", descriptorConfig.RateLimit.Unit)))
}

var unitMultiplier uint32
if descriptorConfig.RateLimit.UnitMultiplier == nil {
unitMultiplier = 1
} else {
unitMultiplier = *descriptorConfig.RateLimit.UnitMultiplier
if unitMultiplier == 0 {
panic(newRateLimitConfigError(
config.Name,
"invalid unit multiplier of 0"))
}
}

replaces := make([]string, len(descriptorConfig.RateLimit.Replaces))
for i, e := range descriptorConfig.RateLimit.Replaces {
replaces[i] = e.Name
Expand All @@ -168,10 +184,12 @@ func (this *rateLimitDescriptor) loadDescriptors(config RateLimitConfigToLoad, p
descriptorConfig.RateLimit.RequestsPerUnit, pb.RateLimitResponse_RateLimit_Unit(value),
statsManager.NewStats(newParentKey), unlimited, descriptorConfig.ShadowMode,
descriptorConfig.RateLimit.Name, replaces, descriptorConfig.DetailedMetric,
unitMultiplier,
)

rateLimitDebugString = fmt.Sprintf(
" ratelimit={requests_per_unit=%d, unit=%s, unlimited=%t, shadow_mode=%t}", rateLimit.Limit.RequestsPerUnit,
rateLimit.Limit.Unit.String(), rateLimit.Unlimited, rateLimit.ShadowMode)
" ratelimit={requests_per_unit=%d, unit=%s, unit_multiplier=%d, unlimited=%t, shadow_mode=%t}", rateLimit.Limit.RequestsPerUnit,
rateLimit.Limit.Unit.String(), unitMultiplier, rateLimit.Unlimited, rateLimit.ShadowMode)

for _, replaces := range descriptorConfig.RateLimit.Replaces {
if replaces.Name == "" {
Expand Down Expand Up @@ -302,6 +320,7 @@ func (this *rateLimitConfigImpl) GetLimit(
"",
[]string{},
false,
1,
)
return rateLimit
}
Expand Down Expand Up @@ -354,7 +373,10 @@ func (this *rateLimitConfigImpl) GetLimit(
descriptorsMap = nextDescriptor.descriptors
} else {
if rateLimit != nil && rateLimit.DetailedMetric {
rateLimit = NewRateLimit(rateLimit.Limit.RequestsPerUnit, rateLimit.Limit.Unit, this.statsManager.NewStats(rateLimit.FullKey), rateLimit.Unlimited, rateLimit.ShadowMode, rateLimit.Name, rateLimit.Replaces, rateLimit.DetailedMetric)
rateLimit = NewRateLimit(rateLimit.Limit.RequestsPerUnit, rateLimit.Limit.Unit,
this.statsManager.NewStats(rateLimit.FullKey), rateLimit.Unlimited,
rateLimit.ShadowMode, rateLimit.Name, rateLimit.Replaces,
rateLimit.DetailedMetric, rateLimit.Limit.UnitMultiplier)
}

break
Expand Down
1 change: 1 addition & 0 deletions src/config/config_xds.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func rateLimitPolicyPbToYaml(pb *rls_conf_v3.RateLimitPolicy) *YamlRateLimit {
return &YamlRateLimit{
RequestsPerUnit: pb.RequestsPerUnit,
Unit: pb.Unit.String(),
UnitMultiplier: pb.UnitMultiplier,
Unlimited: pb.Unlimited,
Name: pb.Name,
Replaces: rateLimitReplacesPbToYaml(pb.Replaces),
Expand Down
5 changes: 3 additions & 2 deletions src/limiter/base_limiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ func (this *BaseRateLimiter) GetResponseDescriptorStatus(key string, limitInfo *
// similar to mongo_1h, mongo_2h, etc. In the hour 1 (0h0m - 0h59m), the cache key is mongo_1h, we start
// to get ratelimited in the 50th minute, the ttl of local_cache will be set as 1 hour(0h50m-1h49m).
// In the time of 1h1m, since the cache key becomes different (mongo_2h), it won't get ratelimited.
err := this.localCache.Set([]byte(key), []byte{}, int(utils.UnitToDivider(limitInfo.limit.Limit.Unit)))

err := this.localCache.Set([]byte(key), []byte{}, int(utils.UnitToDividerWithMultiplier(limitInfo.limit.Limit.Unit, limitInfo.limit.Limit.UnitMultiplier)))
if err != nil {
logger.Errorf("Failing to set local cache key: %s", key)
}
Expand Down Expand Up @@ -205,7 +206,7 @@ func (this *BaseRateLimiter) generateResponseDescriptorStatus(responseCode pb.Ra
Code: responseCode,
CurrentLimit: limit,
LimitRemaining: limitRemaining,
DurationUntilReset: utils.CalculateReset(&limit.Unit, this.timeSource),
DurationUntilReset: utils.CalculateReset(&limit.Unit, this.timeSource, limit.UnitMultiplier),
}
} else {
return &pb.RateLimitResponse_DescriptorStatus{
Expand Down
3 changes: 2 additions & 1 deletion src/limiter/cache_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ func (this *CacheKeyGenerator) GenerateCacheKey(
b.WriteByte('_')
}

divider := utils.UnitToDivider(limit.Limit.Unit)
divider := utils.UnitToDividerWithMultiplier(limit.Limit.Unit, limit.Limit.UnitMultiplier)

b.WriteString(strconv.FormatInt((now/divider)*divider, 10))

return CacheKey{
Expand Down
2 changes: 1 addition & 1 deletion src/memcached/cache_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func (this *rateLimitMemcacheImpl) increaseAsync(cacheKeys []limiter.CacheKey, i

_, err := this.client.Increment(cacheKey.Key, hitsAddend)
if err == memcache.ErrCacheMiss {
expirationSeconds := utils.UnitToDivider(limits[i].Limit.Unit)
expirationSeconds := utils.UnitToDividerWithMultiplier(limits[i].Limit.Unit, limits[i].Limit.UnitMultiplier)
if this.expirationJitterMaxSeconds > 0 {
expirationSeconds += this.jitterRand.Int63n(this.expirationJitterMaxSeconds)
}
Expand Down
2 changes: 1 addition & 1 deletion src/redis/fixed_cache_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func (this *fixedRateLimitCacheImpl) DoLimit(

logger.Debugf("looking up cache key: %s", cacheKey.Key)

expirationSeconds := utils.UnitToDivider(limits[i].Limit.Unit)
expirationSeconds := utils.UnitToDividerWithMultiplier(limits[i].Limit.Unit, limits[i].Limit.UnitMultiplier)
if this.baseRateLimiter.ExpirationJitterMaxSeconds > 0 {
expirationSeconds += this.baseRateLimiter.JitterRand.Int63n(this.baseRateLimiter.ExpirationJitterMaxSeconds)
}
Expand Down
5 changes: 3 additions & 2 deletions src/service/ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,9 @@ func (this *service) constructLimitsToCheck(request *pb.RateLimitRequest, ctx co
logger.Debugf("descriptor is unlimited, not passing to the cache")
} else {
logger.Debugf(
"applying limit: %d requests per %s, shadow_mode: %t",
"applying limit: %d requests per %d %s, shadow_mode: %t",
limitsToCheck[i].Limit.RequestsPerUnit,
limitsToCheck[i].Limit.UnitMultiplier,
limitsToCheck[i].Limit.Unit.String(),
limitsToCheck[i].ShadowMode,
)
Expand Down Expand Up @@ -262,7 +263,7 @@ func (this *service) rateLimitResetHeader(
) *core.HeaderValue {
return &core.HeaderValue{
Key: this.customHeaderResetHeader,
Value: strconv.FormatInt(utils.CalculateReset(&descriptor.CurrentLimit.Unit, this.customHeaderClock).GetSeconds(), 10),
Value: strconv.FormatInt(utils.CalculateReset(&descriptor.CurrentLimit.Unit, this.customHeaderClock, descriptor.CurrentLimit.UnitMultiplier).GetSeconds(), 10),
}
}

Expand Down
18 changes: 16 additions & 2 deletions src/utils/utilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,22 @@ func UnitToDivider(unit pb.RateLimitResponse_RateLimit_Unit) int64 {
panic("should not get here")
}

func CalculateReset(unit *pb.RateLimitResponse_RateLimit_Unit, timeSource TimeSource) *durationpb.Duration {
sec := UnitToDivider(*unit)
// Convert a rate limit into a time divider reflecting the unit multiplier.
// @param unit supplies the unit to convert.
// @param unitMultiplier supplies the unit multiplier to scale the unit with.
// @return the scaled divider to use in time computations.
func UnitToDividerWithMultiplier(unit pb.RateLimitResponse_RateLimit_Unit, unitMultiplier uint32) int64 {
// TODO: Should this stay as a safe-guard?
// Already validated in rateLimitDescriptor::loadDescriptors, here redundantly to avoid the risk of multiplying with 0
if unitMultiplier == 0 {
unitMultiplier = 1
}

return UnitToDivider(unit) * int64(unitMultiplier)
}

func CalculateReset(unit *pb.RateLimitResponse_RateLimit_Unit, timeSource TimeSource, unitMultiplier uint32) *durationpb.Duration {
sec := UnitToDividerWithMultiplier(*unit, unitMultiplier)
now := timeSource.UnixNow()
return &durationpb.Duration{Seconds: sec - now%sec}
}
Expand Down
1 change: 1 addition & 0 deletions test/config/basic_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,4 @@ descriptors:
rate_limit:
unit: minute
requests_per_unit: 70
unit_multiplier: 5
39 changes: 39 additions & 0 deletions test/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -952,3 +952,42 @@ func TestDetailedMetric(t *testing.T) {
})
}
}

func TestUnitMultiplier(t *testing.T) {
assert := assert.New(t)
stats := stats.NewStore(stats.NewNullSink(), false)

rlConfig := config.NewRateLimitConfigImpl(loadFile("unit_multiplier.yaml"), mockstats.NewMockStatManager(stats), false)
rlConfig.Dump()

rl := rlConfig.GetLimit(
context.TODO(), "test-domain",
&pb_struct.RateLimitDescriptor{
Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key1"}},
})

assert.EqualValues(20, rl.Limit.RequestsPerUnit)
assert.Equal(pb.RateLimitResponse_RateLimit_MINUTE, rl.Limit.Unit)
assert.EqualValues(5, rl.Limit.UnitMultiplier)

rl = rlConfig.GetLimit(
context.TODO(), "test-domain",
&pb_struct.RateLimitDescriptor{
Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key2"}},
})

assert.EqualValues(25, rl.Limit.RequestsPerUnit)
assert.Equal(pb.RateLimitResponse_RateLimit_MINUTE, rl.Limit.Unit)
assert.EqualValues(1, rl.Limit.UnitMultiplier)
}

func TestZeroUnitMultiplier(t *testing.T) {
expectConfigPanic(
t,
func() {
config.NewRateLimitConfigImpl(
loadFile("zero_unit_multiplier.yaml"),
mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false)
},
"zero_unit_multiplier.yaml: invalid unit multiplier of 0")
}
14 changes: 14 additions & 0 deletions test/config/unit_multiplier.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
domain: test-domain
descriptors:
# some unit_multiplier
- key: key1
rate_limit:
unit: minute
requests_per_unit: 20
unit_multiplier: 5

# no unit_multiplier
- key: key2
rate_limit:
unit: minute
requests_per_unit: 25
8 changes: 8 additions & 0 deletions test/config/zero_unit_multiplier.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
domain: test-domain
descriptors:
# 0 unit_multiplier
- key: key1
rate_limit:
unit: second
requests_per_unit: 5
unit_multiplier: 0
Loading