-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Limiter extension API interfaces (**draft 4**) #12953
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
Conversation
risks wasting memory. In general, an overloaded limiter that is | ||
saturated SHOULD fail requests immediately. | ||
|
||
Limiter implementations SHOULD consider the context deadline when |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should SHOULD be MUST? I tend to think considering context deadline should be mandatory. But maybe I'm being pedantic - did you just mean here that limiters MAY choose not to block if they can anticipate that the context deadline will come before the limiter is no longer saturated?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Following @bogdandrutu's suggestion, I've separated the MustDeny method (which takes only context) from the Acquire/Limit methods which are weight-dependent.
Limiters implementations MAY block the request or fail immediately, | ||
subject to internal logic. A limiter aims to avoid waste, which |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the internal logic? Are you saying this is a property of the limiter, rather than the caller?
If we made it caller-defined, then I think we could get rid of the MustDeny call: instead you would make a non-blocking request for 0 items. If the limiter is saturated, that would return an error; otherwise it would return success without affecting the capacity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have raised a question about non-blocking methods in the open-questions section of the README.
Reviewers, please see the open questions: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The public API looks good, some naming suggestions and some go implementation in the helper.
|
||
// Checker is for checking when a limit is saturated. This can be | ||
// called prior to the start of work to check for limiter saturation. | ||
type Checker interface { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Up for discussion (not sure): Should this be the "basic" limiter, then call it "Limiter" instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had actually named this Limiter
in an earlier draft. Yes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't care all that much, but "Check" seems more appropriate than "Limit" to me. Although the outcome of checking the saturation/threshold/capacity/whatever is still limiting, we're not presenting a thing to be limited to the API - we're just checking whether the saturation/threshold/capacity/whatever has been reached.
Regardless of that, I have a nit: can we please keep the interface and method name consistent? e.g. rather than type Checker interface {MustDeny(context.Context) error}
, prefer type SaturationChecker interface {CheckSaturation(context.Context) error}
.
var err error | ||
for _, lim := range ls { | ||
if lim == nil { | ||
continue | ||
} | ||
err = errors.Join(err, lim.MustDeny(ctx)) | ||
} | ||
return err |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
errors.Join
-> is a bit inefficient when lim.MustDeny(ctx)
returns nil. I prefer the multierr.Append
which does a better job.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
// CheckerProvider is an interface to obtain checkers for a group of | ||
// weight keys. | ||
type CheckerProvider interface { | ||
// GetChecker returns a checker for a group of weight keys. | ||
GetChecker(...Option) (Checker, error) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the point of having the extra layer of "Provider" for the "Checker"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking of the Option as a placeholder for the open questions, maybe the way to pass the Signal and Component identity. If the actual limiter instance has the options, I expect the basic limiter instance to have the options too.
// checked at a certain stage. The receiver and middleware can both | ||
// be responsible for applying limits, and this type helps ensure | ||
// limits are applied only across cooperating sub-components. | ||
type WeightSet []WeightKey |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you please add this later when the first usage comes? I don't want to argue more about this PR, and I am unable to understand where this is used.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is very much not a big deal to me, we can use []WeightKey
instead.
The first usage is inside this PR, the helper method Contains used to prevent double-limiting request count in middlware.
Examine how the receiver and middleware cooperate to not limit the same thing twice. The receiver knows which weight keys are applied in middleware (because it knows) and it configures a limiterhelper wrapper for the remaining weight keys. I've listed the standard weight keys here, to try and help users see the mechanic.
The wrapper and the middleware are both able to configure request_count limits. For a push-based receiver, we do this in middleware, but for an arbitrary receiver we might put this logic in the wrapper because it is natural there too: in that case, you would pass three keys to the wrapper). The wrapper could be used to add a made-up network bytes limit too, maybe using the uncompressed size of the data as a proxy for receivers that do not use middleware.
// StandardNotMiddlewareKeys methods return the list of middleware | ||
// keys that can be automatically configured through middleware and | ||
// not. | ||
type WeightKey string |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure how important this is, do you see a big usage of this on the critical path? If yes, should we use an enum as int instead, since that will be a bit faster for type checks?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My expectation is that weight keys are used during Start() where the providers are called to initialize the actual limiter instances. We have discussed some ideas about new things that could be rate limited, and I believe @axw previously suggested this might be an open set, not a closed one; either way the goal should be to bind limiters once via a provider and use them w/o passing around weight keys at runtime.
CheckerProvider | ||
|
||
// GetRateLimiter returns a rate limiter for a weight key. | ||
GetRateLimiter(WeightKey, ...Option) (RateLimiter, error) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you think we should have different Option
s for different providers (different types so we can expand them independently)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking about passing in Signal kind, Component ID via these options. I wouldn't mind making these two parameters obligatory, but I don't know how to make it so--the confighttp and configgrpc wrappers don't have this info either.
// configmiddleware or limiterhelper is responsible for constructing | ||
// the correct wrapper from these two kinds of limiter; users will use | ||
// this interface consistently. | ||
type LimiterWrapper interface { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you expect others to implement this interface? Otherwise we should have this as a struct.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see the point in the style you're after -- instead of mocking an interface, you should pass in an adapter and place a mock call inside your function.
// the appropriate interface for callers that can easily wrap a | ||
// function call, because for wrapped calls there is no distinction | ||
// between rate limiters and resource limiters. | ||
type LimiterWrapperProvider interface { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you expect others to implement this interface? Otherwise we should have this as a struct.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. I do not need this to be an interface, however I wonder about all the mock-based tests I've ever written with gomock
and so on, whether we lose access to this style of test. I'll make this a struct.
- The protocol name | ||
- The signal kind | ||
- The caller's component ID |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not convinced about protocol, but I see the other 2 being useful.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure. Note that protocol is already distinguished through middleware configuration, so a user could configure separate limiters if the distinction matters.
functions. No examples are provided. How will limiters configure, for | ||
example, tenant-specific limits? | ||
|
||
##### Data-dependent limits |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If #39199 is accepted, do you still need support here?
|
||
// Checker is for checking when a limit is saturated. This can be | ||
// called prior to the start of work to check for limiter saturation. | ||
type Checker interface { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to support a limiter that is just "Checker" like the current memory limiter? I would suggest yes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. So, would you say there are three fundamental limiter APIs, the Rate/Resource and Basic? There is a provider for each -- does it make sense, to you, that there Rate/Resource providers embed the basic limiter provider (as I have called CheckerProvider
in this PR)?
Callers would be expected to check for more-specific extension APIs before the less-specific one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like it
…tor into jmacd/limiter_v4
…tor into jmacd/limiter_v4
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apologies for the delay. LGTM overall, just a few minor comments - I think naming could be improved, but that won't affect the API significantly.
keys. Because a `Checker` can be consulted more than once by a | ||
receiver and/or middleware, it is possible for requests to be denied | ||
over the saturation of limits they were already granted. Users should |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand this. I mean I understand that if you call the limiter first, and then check saturation that the latter may fail. Why would you do that? Isn't that a logic error in the caller, or are there legitimate scenarios where you would do this...?
as follows. The HTTP client config object's `middlewares` field | ||
automatically configures network bytes and request count limits: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Re network bytes: would that be limiting on the response body size, and wrapping the net/http.Response.Body
for HTTP?
Another option is to add support for non-blocking limit requests. For | ||
example, to apply limits using information derived from the | ||
OpenTelemetry resource, we might do something like this pseudo-code: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we could do something like this with the partitioning processor:
- split data by resource
- configure a limiter to silently drop data without error when saturated
|
||
// Checker is for checking when a limit is saturated. This can be | ||
// called prior to the start of work to check for limiter saturation. | ||
type Checker interface { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't care all that much, but "Check" seems more appropriate than "Limit" to me. Although the outcome of checking the saturation/threshold/capacity/whatever is still limiting, we're not presenting a thing to be limited to the API - we're just checking whether the saturation/threshold/capacity/whatever has been reached.
Regardless of that, I have a nit: can we please keep the interface and method name consistent? e.g. rather than type Checker interface {MustDeny(context.Context) error}
, prefer type SaturationChecker interface {CheckSaturation(context.Context) error}
.
// MiddlewareIsLimiter returns true if a middleware configuration | ||
// represents a valid limiter, returns false for not found or invalid | ||
// cases. If the named extension is found but is not a limiter, | ||
// returns (false, nil). | ||
func MiddlewareIsLimiter(host component.Host, middleware configmiddleware.Config) (bool, error) { | ||
_, ok, err := middlewareIsLimiter(host, middleware) | ||
return ok, err | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need this, given that MiddlewareToLimiterWrapperProvider will return ErrNotALimiter? i.e. we could just call MiddlewareToLimiterWrapperProvider and check for that error.
@axw Apologies, I have created a new draft github.com//pull/13051. I mean to incorporate all of the feedback above. In particular, I would note that I've reverted the name of the basic limiter to |
Description
See #12603. This follows discussion in #12700 and has been updated extensively based on feedback. This PR is too large to merge as-is but can be taken as a model for a series of smaller PRs.
Link to tracking issue
Part of #9591.
Testing
TODO
Documentation
Done.