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: email, sms rate limiting new config and refactored rate limiters #1746

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

hf
Copy link
Contributor

@hf hf commented Aug 28, 2024

Adds a new config syntax that allows more flexible rate limiting configuration for the GOTRUE_RATE_LIMIT_EMAIL_SENT, GOTRUE_RATE_LIMIT_SMS_SENT configs. It can be added in follow up PRs to the other configs.

It's fully backward compatible.

The syntax is:

  • 123 or 123.0 expresses 123 events with an unspecified time, this is the legacy case and is on a per-case basis (see DivideAndSetDuration)
  • 123/24h expresses 123 emails sent per day (token bucket resets every 24 hours)

Additionally the rate limiters for SMS and email are replaced with simple counters, massively simplifying the code paths and decreasing reliance on the tollbooth library which has memory leaks.

You can use any time.ParseDuration() value in the second part of the rate config.

@hf hf requested a review from a team as a code owner August 28, 2024 13:05
@hf hf force-pushed the hf/email-rate-limiting-new-config branch 2 times, most recently from 4bc81f3 to f498771 Compare August 28, 2024 13:15
@coveralls
Copy link

coveralls commented Aug 28, 2024

Pull Request Test Coverage Report for Build 10597475519

Details

  • 40 of 49 (81.63%) changed or added relevant lines in 2 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage increased (+0.06%) to 57.948%

Changes Missing Coverage Covered Lines Changed/Added Lines %
internal/conf/rate.go 39 48 81.25%
Totals Coverage Status
Change from base Build 10588384860: 0.06%
Covered Lines: 9176
Relevant Lines: 15835

💛 - Coveralls

internal/api/middleware.go Outdated Show resolved Hide resolved
@hf hf force-pushed the hf/email-rate-limiting-new-config branch 2 times, most recently from 158f705 to 9d89e34 Compare September 10, 2024 17:33
@hf hf changed the title fix: add new config syntax for email rate limiting feat: email, sms rate limiting new config and refactored rate limiters Sep 10, 2024
return *r
}

func (r *Rate) DivideIfDefaultDuration(div float64) *Rate {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Remove

return r
}

func (r *Rate) CreateLimiter() *limiter.Limiter {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Remove

return fmt.Sprintf("%f", r.Events)
}

return fmt.Sprintf("%f/%s", r.Events, r.OverTime.String())
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Suggested change
return fmt.Sprintf("%f/%s", r.Events, r.OverTime.String())
return fmt.Sprintf("%d/%s", r.Events, r.OverTime.String())

Copy link
Member

@kangmingtay kangmingtay left a comment

Choose a reason for hiding this comment

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

lgtm - left some comments to improve readability

Comment on lines +14 to +12
Events float64 `json:"events,omitempty"`
OverTime time.Duration `json:"over_time,omitempty"`
Copy link
Member

Choose a reason for hiding this comment

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

for clarification: Events keeps track of the number of requests allowed per second while OverTime is when the bucket gets refilled right? if so, i would just prefer to call OverTime the ExpirationTTL

Copy link
Contributor

@J0 J0 Sep 10, 2024

Choose a reason for hiding this comment

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

/nit As a follow on suggestion, perhaps we can consider renaming events to AllowedEvents or similar

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'd prefer to not use "TTL" or "Expire" because it's not about expiring, it's describing allowing number of "Events" occurring "OverTime".

How that's implemented in rate limiting can vary.

func (r *Rate) CreateLimiter() *limiter.Limiter {
overTime := r.OverTime
if int64(overTime) == 0 {
// if r.OverTime is not specified, i.e. the configuration specified just a single float64 number, the
Copy link
Member

Choose a reason for hiding this comment

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

incomplete comment but i'm guessing you meant we default the overTime to 1 hour

return atomic.AddUint64(&r.counter, events) < uint64(r.Rate.Events)
}

func (r *SimpleRateLimiter) fillBucket(initialWait time.Duration) {
Copy link
Member

Choose a reason for hiding this comment

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

hmm this function is basically counting down to the bucket expiry and resetting the counter right? should we name this something else instead? naming it fillBucket confused me a little and i thought this was where we incremented the counter

})
}

func (r *Rate) Decode(value string) error {
Copy link
Member

Choose a reason for hiding this comment

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

can we add a comment to describe that decode is necessary for envconfig to unmarshal the value correctly?

Comment on lines +244 to +245
RateLimitEmailSent Rate `split_words:"true" default:"30"`
RateLimitSmsSent Rate `split_words:"true" default:"30"`
Copy link
Member

Choose a reason for hiding this comment

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

do we want to keep the default setting here to 30?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup, no breaking changes

return fmt.Errorf("rate: value does not match rate syntax %q", value)
}

e, err := strconv.ParseUint(parts[0], 10, 52)
Copy link
Contributor

Choose a reason for hiding this comment

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

Would this be set to 52 to account for the mantissa? Consider adding a comment as it might not be immediately obvious


go r.fillBucket(counterResetsAt.Sub(now))

return nil
Copy link
Contributor

Choose a reason for hiding this comment

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

should this return the rate limiter?

Suggested change
return nil
return r

@J0
Copy link
Contributor

J0 commented Sep 10, 2024

Nice work, don't forget to remove withLimiter and getLimiter as the Github warnings suggest

@hf hf force-pushed the hf/email-rate-limiting-new-config branch from 9d89e34 to f05a4b7 Compare September 11, 2024 14:11

atomic.StoreUint64(&r.counter, 0)

ticker := time.NewTicker(r.Rate.OverTime)
Copy link
Contributor

Choose a reason for hiding this comment

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

defer ticker.Stop()

return atomic.AddUint64(&r.counter, events) < uint64(r.Rate.Events)
}

func (r *SimpleRateLimiter) fillBucket(initialWait time.Duration) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this runs in a goroutine, is it possible we accept a context here by refactoring the responsibility of token filling? Alternatively this computation can be done at the time of each call to Increment. A reference implementation (an potential candidate for this, which includes context) could be:

https://pkg.go.dev/golang.org/x/time/rate


r.counter = uint64(rate.Events * proRate)

go r.fillBucket(counterResetsAt.Sub(now))
Copy link
Contributor

Choose a reason for hiding this comment

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

We solve a few potential caveats by moving the responsibility of starting the goroutine into the caller, providing a context to select on along with ticker to prevent goroutine leaks over time. Alternatively as suggested in the other comment we could remove the real-time bucket filling into a task to be done during incrementing.

@@ -70,6 +73,11 @@ func (a *API) deprecationNotices() {
func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Connection, version string) *API {
api := &API{config: globalConfig, db: db, version: version}

now := time.Now()

api.emailRateLimiter = NewSimpleRateLimiter(now, globalConfig.RateLimitEmailSent.DivideAndSetDuration(6*60, time.Hour))
Copy link
Contributor

Choose a reason for hiding this comment

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

These could be parameterized in NewAPIWithVersion so it doesn't need to be responsible for starting goroutines, alternatively we provide a context.Context to signal when shutdown has occurred. This would allow us to signal cancelation and synchronize exiting.

atomic.StoreUint64(&r.counter, 0)

// then keep resetting at regular OverTime intervals
for range r.ticker.C {
Copy link
Contributor

Choose a reason for hiding this comment

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

You will need another mechanism to signal this goroutine should exit, because r.ticker.C will not be closed when r.ticker.Stop is called, leaving this blocked forever.

One option is to add ctx, another is to have a close chan so you can select between the ticker and r.closeCh here which could be close(r.closeCh) in r.Stop(), though you will want to add a mutex at that point to defend against double closes to allow flexibility for callers.

}
}

func (r *SimpleRateLimiter) Close() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the intention here to have callers of New() (serve_cmd, Reloader, ...) call Close? Workable solution but we may need to change the fillBucket some to ensure it exits, see other comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants