Skip to content

Commit

Permalink
feat(translator): implement ratelimit costs (#5035)
Browse files Browse the repository at this point in the history
* feat(translator): implement ratelimit costs

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

* fix

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

* fix

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

* fix

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

* more

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

* works now

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

* lint

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

* fixes comments

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

* fixes comments

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

* adds the requested test

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

* more

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

* gen

Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>

---------

Signed-off-by: Takeshi Yoneda <t.y.m
  • Loading branch information
mathetake authored Jan 15, 2025
1 parent da987da commit 3e35b12
Show file tree
Hide file tree
Showing 17 changed files with 549 additions and 15 deletions.
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 @@ package translator

import (
"bytes"
"fmt"
"net/url"
"strconv"
"strings"
Expand All @@ -26,6 +27,7 @@ import (
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 @@ func (t *Translator) buildRateLimitFilter(irListener *ir.HTTPListener) *hcmv3.Ht
}

rateLimitFilter := &hcmv3.HttpFilter{
Name: wellknown.HTTPRateLimit,
Name: egv1a1.EnvoyFilterRateLimit.String(),
ConfigType: &hcmv3.HttpFilter_TypedConfig{
TypedConfig: rateLimitFilterAny,
},
Expand All @@ -127,20 +129,60 @@ func (t *Translator) buildRateLimitFilter(irListener *ir.HTTPListener) *hcmv3.Ht
}

// 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
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)
}

g, err := anypb.New(&ratelimitfilterv3.RateLimitPerRoute{RateLimits: rateLimits})
if err != nil {
return fmt.Errorf("failed to marshal per-route ratelimit filter config: %w", err)
}
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 @@ func buildRouteRateLimits(descriptorPrefix string, global *ir.GlobalRateLimit) [
}

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

0 comments on commit 3e35b12

Please sign in to comment.