hmn/src/discord/ratelimiting.go

289 lines
7.2 KiB
Go
Raw Normal View History

package discord
import (
"context"
"errors"
"math"
"net/http"
"strconv"
"sync"
"time"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/utils"
)
var limiterLog = logging.GlobalLogger().With().
Str("module", "discord").
Str("discord actor", "rate limiter").
Logger()
var buckets sync.Map // map[route name]bucket name
var rateLimiters sync.Map // map[bucket name]*restRateLimiter
var limiterInitMutex sync.Mutex
type restRateLimiter struct {
requests chan struct{}
refills chan rateLimiterRefill
}
type rateLimiterRefill struct {
resetAfter time.Duration
maxRequests int
}
/*
Whenever we send a request, we must sleep until this time
(if it is in the future, of course). This is a quick and
dirty way to pause all sending in case of a global rate
limit.
I could put a mutex on this but I don't think it's actually
a problem to have race conditions here. Just set it when
you get throttled. EZ.
*/
var globalRateLimitTime time.Time
type rateLimitHeaders struct {
Bucket string
Limit int
Remaining int
ResetAfter time.Duration
}
func parseRateLimitHeaders(header http.Header) (rateLimitHeaders, bool) {
var err error
bucket := header.Get("X-RateLimit-Bucket")
var limit int
var remaining int
var resetAfter time.Duration
limitStr := header.Get("X-RateLimit-Limit")
if limitStr != "" {
limit, err = strconv.Atoi(limitStr)
if err != nil {
limiterLog.Error().
Err(err).
Str("value", limitStr).
Msg("failed to parse X-RateLimit-Limit header")
return rateLimitHeaders{}, false
}
}
remainingStr := header.Get("X-RateLimit-Remaining")
if remainingStr != "" {
remaining, err = strconv.Atoi(remainingStr)
if err != nil {
limiterLog.Error().
Err(err).
Str("value", remainingStr).
Msg("failed to parse X-RateLimit-Remaining header")
return rateLimitHeaders{}, false
}
}
resetAfterStr := header.Get("X-RateLimit-Reset-After")
if resetAfterStr != "" {
resetAfterSeconds, err := strconv.ParseFloat(resetAfterStr, 64)
if err != nil {
limiterLog.Error().
Err(err).
Str("value", resetAfterStr).
Msg("failed to parse X-RateLimit-Reset-After header")
return rateLimitHeaders{}, false
}
resetAfter = time.Duration(math.Ceil(resetAfterSeconds)) * time.Second
}
return rateLimitHeaders{
Bucket: bucket,
Limit: limit,
Remaining: remaining,
ResetAfter: resetAfter,
}, true
}
func createLimiter(headers rateLimitHeaders, routeName string) {
limiterInitMutex.Lock()
defer limiterInitMutex.Unlock()
buckets.Store(routeName, headers.Bucket)
ilimiter, loaded := rateLimiters.LoadOrStore(headers.Bucket, &restRateLimiter{
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
requests: make(chan struct{}, 200), // presumably this is big enough to handle bursts
refills: make(chan rateLimiterRefill),
})
if !loaded {
limiter := ilimiter.(*restRateLimiter)
log := limiterLog.With().Str("bucket", headers.Bucket).Logger()
prefillloop:
// Pre-fill the limiter with remaining requests
for i := 0; i < headers.Remaining; i++ {
select {
case limiter.requests <- struct{}{}:
default:
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
log.Warn().
Int("remaining", headers.Remaining).
Msg("rate limiting channel was too small; you should increase the default capacity")
break prefillloop
}
}
/*
Start the refiller for this bucket. It waits for a request to tell
it when to next reset the rate limit, and how full to fill the bucket.
It then sleeps and refills the bucket, just like it should :)
*/
go func() {
for {
// Wake up on the first request after refilling
refill := <-limiter.refills
// Sleep for the remainder of the bucket's time
time.Sleep(refill.resetAfter)
drainloop:
// drain the bucket
for {
select {
case <-limiter.requests:
default:
break drainloop
}
}
refillloop:
// refill it with the max number of requests
for i := 0; i < refill.maxRequests; i++ {
select {
case limiter.requests <- struct{}{}:
default:
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
log.Warn().
Int("maxRequests", refill.maxRequests).
Msg("rate limiting channel was too small; you should increase the default capacity")
break refillloop
}
}
// And then we wait again to hear about our next
// bucket's worth of requests.
}
}()
// Tell the refiller about its first refill
limiter.refills <- rateLimiterRefill{
resetAfter: headers.ResetAfter,
maxRequests: headers.Limit,
}
}
}
func (l *restRateLimiter) update(headers rateLimitHeaders) {
refill := rateLimiterRefill{
resetAfter: headers.ResetAfter,
maxRequests: headers.Limit,
}
/*
Tell the refiller about this request. If the refiller is already
busy sleeping, this will have no effect, which is what we want.
(It's already sleeping for as long as it needs to.)
*/
select {
case l.refills <- refill:
default:
}
}
func doWithRateLimiting(ctx context.Context, routeName string, getReq func(ctx context.Context) *http.Request) (*http.Response, error) {
var bucket string
ibucket, ok := buckets.Load(routeName)
if ok {
bucket = ibucket.(string)
}
for {
var limiter *restRateLimiter
if bucket != "" {
ilimiter, ok := rateLimiters.Load(bucket)
if ok {
limiter = ilimiter.(*restRateLimiter)
}
}
if globalRateLimitTime.After(time.Now()) {
// oh boy, global rate limit, pause until the coast is clear
err := utils.SleepContext(ctx, globalRateLimitTime.Sub(time.Now())+1*time.Second)
if err != nil {
return nil, err
}
}
if limiter != nil {
select {
case <-limiter.requests:
case <-ctx.Done():
return nil, errors.New("request interrupted during rate limiting")
}
}
res, err := httpClient.Do(getReq(ctx))
if err != nil {
return nil, err
}
headers, headersOk := parseRateLimitHeaders(res.Header)
if headersOk {
if limiter == nil || headers.Bucket != bucket {
createLimiter(headers, routeName)
} else {
limiter.update(headers)
}
}
if res.StatusCode == 429 {
if res.Header.Get("X-RateLimit-Global") != "" {
// globally rate limited
logging.ExtractLogger(ctx).Warn().Msg("got globally rate limited by Discord")
retryAfter, err := strconv.Atoi(res.Header.Get("Retry-After"))
if err == nil {
globalRateLimitTime = time.Now().Add(time.Duration(retryAfter) * time.Second)
} else {
// well this is bad, just sleep for 60 seconds and pray that it's long enough
logging.ExtractLogger(ctx).Warn().
Err(err).
Msg("got globally rate limited but couldn't determine how long to wait")
globalRateLimitTime = time.Now().Add(60 * time.Second)
}
} else {
// locally rate limited
/*
Despite our best efforts, we ended up rate limited anyway.
Simply wait the amount of time Discord asks, and then try
again. On the next go-around, hopefully we'll either succeed
or have a rate limiter initialized and ready to go.
*/
logging.ExtractLogger(ctx).Warn().Msg("got rate limited by Discord")
if headersOk {
err := utils.SleepContext(ctx, headers.ResetAfter)
if err != nil {
return nil, err
}
} else {
logging.ExtractLogger(ctx).Warn().Msg("got rate limited, but didn't have the headers??")
err := utils.SleepContext(ctx, 1*time.Second)
if err != nil {
return nil, err
}
}
}
continue
}
return res, nil
}
}