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

feat(translator): implement ratelimit costs #5035

Merged
merged 12 commits into from
Jan 15, 2025
Merged
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: 0 additions & 5 deletions api/v1alpha1/ratelimit_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ type RateLimitRule struct {
// the request path and do not reduce the rate limit counters on the response path.
//
// +optional
// +notImplementedHide
Cost *RateLimitCost `json:"cost,omitempty"`
}

Expand All @@ -112,7 +111,6 @@ type RateLimitCost struct {
// enough capacity, the request is rate limited.
//
// +optional
// +notImplementedHide
Request *RateLimitCostSpecifier `json:"request,omitempty"`
// Response specifies the number to reduce the rate limit counters
// after the response is sent back to the client or the request stream is closed.
Expand All @@ -127,7 +125,6 @@ type RateLimitCost struct {
// Currently, this is only supported for HTTP Global Rate Limits.
//
// +optional
// +notImplementedHide
Response *RateLimitCostSpecifier `json:"response,omitempty"`
}

Expand All @@ -143,12 +140,10 @@ type RateLimitCostSpecifier struct {
// Using zero can be used to only check the rate limit counters without reducing them.
//
// +optional
// +notImplementedHide
Number *uint64 `json:"number,omitempty"`
// Metadata specifies the per-request metadata to retrieve the usage number from.
//
// +optional
// +notImplementedHide
Metadata *RateLimitCostMetadata `json:"metadata,omitempty"`
}

Expand Down
17 changes: 16 additions & 1 deletion examples/grpc-ext-proc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (

envoy_api_v3_core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
envoy_service_proc_v3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"

"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
Expand Down Expand Up @@ -329,10 +328,26 @@ func (s *extProcServer) Process(srv envoy_service_proc_v3.ExternalProcessor_Proc
},
},
}

resp = &envoy_service_proc_v3.ProcessingResponse{
Response: &envoy_service_proc_v3.ProcessingResponse_ResponseHeaders{
ResponseHeaders: rhq,
},
DynamicMetadata: &structpb.Struct{
Fields: map[string]*structpb.Value{
"io.envoyproxy.gateway.e2e": {
Kind: &structpb.Value_StructValue{
StructValue: &structpb.Struct{
Fields: map[string]*structpb.Value{
"request_cost_set_by_ext_proc": {
Kind: &structpb.Value_NumberValue{NumberValue: float64(10)},
},
},
},
},
},
},
},
}
break
default:
Expand Down
21 changes: 21 additions & 0 deletions internal/gatewayapi/backendtrafficpolicy.go
Original file line number Diff line number Diff line change
Expand Up @@ -791,9 +791,30 @@ func buildRateLimitRule(rule egv1a1.RateLimitRule) (*ir.RateLimitRule, error) {
irRule.CIDRMatch = cidrMatch
}
}

if cost := rule.Cost; cost != nil {
if cost.Request != nil {
irRule.RequestCost = translateRateLimitCost(cost.Request)
}
if cost.Response != nil {
irRule.ResponseCost = translateRateLimitCost(cost.Response)
}
}
return irRule, nil
}

func translateRateLimitCost(cost *egv1a1.RateLimitCostSpecifier) *ir.RateLimitCost {
ret := &ir.RateLimitCost{}
if cost.Number != nil {
ret.Number = cost.Number
}
if cost.Metadata != nil {
ret.Format = ptr.To(fmt.Sprintf("%%DYNAMIC_METADATA(%s:%s)%%",
cost.Metadata.Namespace, cost.Metadata.Key))
}
return ret
}

func int64ToUint32(in int64) (uint32, bool) {
if in >= 0 && in <= math.MaxUint32 {
return uint32(in), true
Expand Down
25 changes: 25 additions & 0 deletions internal/gatewayapi/backendtrafficpolicy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"testing"

"github.com/stretchr/testify/require"
"k8s.io/utils/ptr"

egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1"
"github.com/envoyproxy/gateway/internal/ir"
Expand Down Expand Up @@ -107,3 +108,27 @@ func TestMakeIrTriggerSet(t *testing.T) {
})
}
}

func Test_translateRateLimitCost(t *testing.T) {
for _, tc := range []struct {
name string
cost *egv1a1.RateLimitCostSpecifier
exp *ir.RateLimitCost
}{
{
name: "number",
cost: &egv1a1.RateLimitCostSpecifier{Number: ptr.To[uint64](1)},
exp: &ir.RateLimitCost{Number: ptr.To[uint64](1)},
},
{
name: "metadata",
cost: &egv1a1.RateLimitCostSpecifier{Metadata: &egv1a1.RateLimitCostMetadata{Namespace: "something.com", Key: "name"}},
exp: &ir.RateLimitCost{Format: ptr.To(`%DYNAMIC_METADATA(something.com:name)%`)},
},
} {
t.Run(tc.name, func(t *testing.T) {
act := translateRateLimitCost(tc.cost)
require.Equal(t, tc.exp, act)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,12 @@ backendTrafficPolicies:
limit:
requests: 20
unit: Hour
cost:
request:
from: Number
number: 1
response:
from: Metadata
metadata:
namespace: something.com
key: some_cost_set_by_foo
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ backendTrafficPolicies:
- sourceCIDR:
type: Distinct
value: 192.168.0.0/16
cost:
request:
from: Number
number: 1
response:
from: Metadata
metadata:
key: some_cost_set_by_foo
namespace: something.com
limit:
requests: 20
unit: Hour
Expand Down Expand Up @@ -370,3 +379,7 @@ xdsIR:
limit:
requests: 20
unit: Hour
requestCost:
number: 1
responseCost:
format: '%DYNAMIC_METADATA(something.com:some_cost_set_by_foo)%'
11 changes: 11 additions & 0 deletions internal/ir/xds.go
Original file line number Diff line number Diff line change
Expand Up @@ -1946,6 +1946,17 @@ type RateLimitRule struct {
CIDRMatch *CIDRMatch `json:"cidrMatch,omitempty" yaml:"cidrMatch,omitempty"`
// Limit holds the rate limit values.
Limit RateLimitValue `json:"limit,omitempty" yaml:"limit,omitempty"`
// RequestCost specifies the cost of the request.
RequestCost *RateLimitCost `json:"requestCost,omitempty" yaml:"requestCost,omitempty"`
// ResponseCost specifies the cost of the response.
ResponseCost *RateLimitCost `json:"responseCost,omitempty" yaml:"responseCost,omitempty"`
}

// RateLimitCost specifies the cost of the request or response.
// +k8s:deepcopy-gen=true
type RateLimitCost struct {
Number *uint64 `json:"number,omitempty" yaml:"number,omitempty"`
Format *string `json:"format,omitempty" yaml:"format,omitempty"`
}

type CIDRMatch struct {
Expand Down
35 changes: 35 additions & 0 deletions internal/ir/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions internal/xds/translator/httpfilters.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,8 @@ func patchRouteWithPerRouteConfig(
}

// RateLimit filter is handled separately because it relies on the global
// rate limit server configuration.
if err := patchRouteWithRateLimit(route.GetRoute(), irRoute); err != nil {
// rate limit server configuration if costs are not provided.
if err := patchRouteWithRateLimit(route, irRoute); err != nil {
return nil
}

Expand Down
78 changes: 71 additions & 7 deletions internal/xds/translator/ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import (
"bytes"
"fmt"
"net/url"
"strconv"
"strings"
Expand All @@ -26,6 +27,7 @@
goyaml "gopkg.in/yaml.v3" // nolint: depguard
"k8s.io/utils/ptr"

egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1"
"github.com/envoyproxy/gateway/internal/ir"
"github.com/envoyproxy/gateway/internal/xds/types"
)
Expand Down Expand Up @@ -118,7 +120,7 @@
}

rateLimitFilter := &hcmv3.HttpFilter{
Name: wellknown.HTTPRateLimit,
Name: egv1a1.EnvoyFilterRateLimit.String(),
ConfigType: &hcmv3.HttpFilter_TypedConfig{
TypedConfig: rateLimitFilterAny,
},
Expand All @@ -127,20 +129,60 @@
}

// patchRouteWithRateLimit builds rate limit actions and appends to the route.
func patchRouteWithRateLimit(xdsRouteAction *routev3.RouteAction, irRoute *ir.HTTPRoute) error { //nolint:unparam
func patchRouteWithRateLimit(route *routev3.Route, irRoute *ir.HTTPRoute) error { //nolint:unparam
// Return early if no rate limit config exists.
xdsRouteAction := route.GetRoute()
if !routeContainsGlobalRateLimit(irRoute) || xdsRouteAction == nil {
return nil
}

rateLimits := buildRouteRateLimits(irRoute.Name, irRoute.Traffic.RateLimit.Global)
global := irRoute.Traffic.RateLimit.Global
rateLimits, costSpecified := buildRouteRateLimits(irRoute.Name, global)
if costSpecified {
// PerRoute global rate limit configuration via typed_per_filter_config can have its own rate routev3.RateLimit that overrides the route level rate limits.
// Per-descriptor level hits_addend can only be configured there: https://github.com/envoyproxy/envoy/pull/37972
// vs the "legacy" core route-embedded rate limits doesn't support the feature due to the "technical debt".
//
// This branch is only reached when the response cost is specified which allows us to assume that
// users are using Envoy >= v1.33.0 which also supports the typed_per_filter_config.
//
// https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ratelimit/v3/rate_limit.proto#extensions-filters-http-ratelimit-v3-ratelimitperroute
//
// Though this is not explicitly documented, the rate limit functionality is the same as the core route-embedded rate limits.
// Only code path different is in the following code which is identical for both core and typed_per_filter_config
// as we are not using virtual_host level rate limits except that when typed_per_filter_config is used, per-descriptor
// level hits_addend is correctly resolved.
//
// https://github.com/envoyproxy/envoy/blob/47f99c5aacdb582606a48c85c6c54904fd439179/source/extensions/filters/http/ratelimit/ratelimit.cc#L93-L114
return patchRouteWithRateLimitOnTypedFilterConfig(route, rateLimits)
}
xdsRouteAction.RateLimits = rateLimits
return nil
}

func buildRouteRateLimits(descriptorPrefix string, global *ir.GlobalRateLimit) []*routev3.RateLimit {
var rateLimits []*routev3.RateLimit
// patchRouteWithRateLimitOnTypedFilterConfig builds rate limit actions and appends to the route via
// the TypedPerFilterConfig field.
func patchRouteWithRateLimitOnTypedFilterConfig(route *routev3.Route, rateLimits []*routev3.RateLimit) error { //nolint:unparam
Copy link
Contributor

Choose a reason for hiding this comment

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

cc @zhaohuabing this introduces another design pattern to do per route filter config

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah i was too lazy to do the right abstraction 😉

Copy link
Member Author

Choose a reason for hiding this comment

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

so anyways when we can set the floor Envoy version to v1.33, then we should be able to migrate to this typed_per_filter_config global rate limit unconditionally since that's the latest way (having support for per-descriptor-hits-addend) vs the current route-embedded config is legacy one

Copy link
Member

@zhaohuabing zhaohuabing Jan 14, 2025

Choose a reason for hiding this comment

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

Yeah, typed_per_filter_config is the way to go - it aligns with the approach used by all other filters for per-route configurations.

We can address htis in a seperate PR later.

filterCfg := route.TypedPerFilterConfig
if filterCfg == nil {
filterCfg = make(map[string]*anypb.Any)
route.TypedPerFilterConfig = filterCfg
}
if _, ok := filterCfg[egv1a1.EnvoyFilterRateLimit.String()]; ok {
// This should not happen since this is the only place where the filter
// config is added in a route.
return fmt.Errorf(
"route already contains global rate limit filter config: %s", route.Name)
}

Check warning on line 175 in internal/xds/translator/ratelimit.go

View check run for this annotation

Codecov / codecov/patch

internal/xds/translator/ratelimit.go#L171-L175

Added lines #L171 - L175 were not covered by tests

g, err := anypb.New(&ratelimitfilterv3.RateLimitPerRoute{RateLimits: rateLimits})
mathetake marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return fmt.Errorf("failed to marshal per-route ratelimit filter config: %w", err)
}

Check warning on line 180 in internal/xds/translator/ratelimit.go

View check run for this annotation

Codecov / codecov/patch

internal/xds/translator/ratelimit.go#L179-L180

Added lines #L179 - L180 were not covered by tests
filterCfg[egv1a1.EnvoyFilterRateLimit.String()] = g
return nil
}

func buildRouteRateLimits(descriptorPrefix string, global *ir.GlobalRateLimit) (rateLimits []*routev3.RateLimit, costSpecified bool) {
// Route descriptor for each route rule action
routeDescriptor := &routev3.RateLimit_Action{
ActionSpecifier: &routev3.RateLimit_Action_GenericKey_{
Expand Down Expand Up @@ -260,10 +302,32 @@
}

rateLimit := &routev3.RateLimit{Actions: rlActions}
if c := rule.RequestCost; c != nil {
rateLimit.HitsAddend = rateLimitCostToHitsAddend(c)
costSpecified = true
}
rateLimits = append(rateLimits, rateLimit)
if c := rule.ResponseCost; c != nil {
// To apply the cost to the response, we need to set ApplyOnStreamDone to true which is per Rule option,
// so we need to create a new RateLimit for the response with the option set.
responseRule := &routev3.RateLimit{Actions: rlActions, ApplyOnStreamDone: true}
responseRule.HitsAddend = rateLimitCostToHitsAddend(c)
rateLimits = append(rateLimits, responseRule)
costSpecified = true
}
}
return
}

return rateLimits
func rateLimitCostToHitsAddend(c *ir.RateLimitCost) *routev3.RateLimit_HitsAddend {
ret := &routev3.RateLimit_HitsAddend{}
if c.Number != nil {
ret.Number = &wrapperspb.UInt64Value{Value: *c.Number}
}
if c.Format != nil {
ret.Format = *c.Format
}
return ret
}

// GetRateLimitServiceConfigStr returns the PB string for the rate limit service configuration.
Expand Down
Loading
Loading