diff options
author | 2023-01-04 11:57:59 +0100 | |
---|---|---|
committer | 2023-01-04 11:57:59 +0100 | |
commit | 90a14abb0c693287d10c5b2b8a6e5515f3ed4c37 (patch) | |
tree | b6cac62664d5bb0eed762e4bb9b8dab33c0da516 /internal | |
parent | [docs] Fix documentation edit link (#1298) (diff) | |
download | gotosocial-90a14abb0c693287d10c5b2b8a6e5515f3ed4c37.tar.xz |
[feature] HTTP request throttling middleware (#1297)
* [feature] Add throttling middleware to AP endpoints
* refactor a lil bit
* use config setting, start updating docs
* doc updates
* use relative links in faq doc
* small docs fixes
* return code 503 instead of 429 when throttled
* throttle other endpoints too
* simplify token channel prefills
Diffstat (limited to 'internal')
-rw-r--r-- | internal/config/config.go | 5 | ||||
-rw-r--r-- | internal/config/defaults.go | 5 | ||||
-rw-r--r-- | internal/config/flags.go | 1 | ||||
-rw-r--r-- | internal/config/helpers.gen.go | 25 | ||||
-rw-r--r-- | internal/middleware/throttling.go | 153 |
5 files changed, 185 insertions, 4 deletions
diff --git a/internal/config/config.go b/internal/config/config.go index 8a2c041e1..d057afe37 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -127,8 +127,9 @@ type Configuration struct { SyslogProtocol string `name:"syslog-protocol" usage:"Protocol to use when directing logs to syslog. Leave empty to connect to local syslog."` SyslogAddress string `name:"syslog-address" usage:"Address:port to send syslog logs to. Leave empty to connect to local syslog."` - AdvancedCookiesSamesite string `name:"advanced-cookies-samesite" usage:"'strict' or 'lax', see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite"` - AdvancedRateLimitRequests int `name:"advanced-rate-limit-requests" usage:"Amount of HTTP requests to permit within a 5 minute window. 0 or less turns rate limiting off."` + AdvancedCookiesSamesite string `name:"advanced-cookies-samesite" usage:"'strict' or 'lax', see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite"` + AdvancedRateLimitRequests int `name:"advanced-rate-limit-requests" usage:"Amount of HTTP requests to permit within a 5 minute window. 0 or less turns rate limiting off."` + AdvancedThrottlingMultiplier int `name:"advanced-throttling-multiplier" usage:"Multiplier to use per cpu for http request throttling. 0 or less turns throttling off."` // Cache configuration vars. Cache CacheConfiguration `name:"cache"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 6d589439a..4873c5c47 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -104,8 +104,9 @@ var Defaults = Configuration{ SyslogProtocol: "udp", SyslogAddress: "localhost:514", - AdvancedCookiesSamesite: "lax", - AdvancedRateLimitRequests: 300, // 1 per second per 5 minutes + AdvancedCookiesSamesite: "lax", + AdvancedRateLimitRequests: 300, // 1 per second per 5 minutes + AdvancedThrottlingMultiplier: 8, // 8 open requests per CPU Cache: CacheConfiguration{ GTS: GTSCacheConfiguration{ diff --git a/internal/config/flags.go b/internal/config/flags.go index c5df1c8b2..3a5d69f25 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -132,6 +132,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) { // Advanced flags cmd.Flags().String(AdvancedCookiesSamesiteFlag(), cfg.AdvancedCookiesSamesite, fieldtag("AdvancedCookiesSamesite", "usage")) cmd.Flags().Int(AdvancedRateLimitRequestsFlag(), cfg.AdvancedRateLimitRequests, fieldtag("AdvancedRateLimitRequests", "usage")) + cmd.Flags().Int(AdvancedThrottlingMultiplierFlag(), cfg.AdvancedThrottlingMultiplier, fieldtag("AdvancedThrottlingMultiplier", "usage")) }) } diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 62894b4d5..de5b93762 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -1824,6 +1824,31 @@ func GetAdvancedRateLimitRequests() int { return global.GetAdvancedRateLimitRequ // SetAdvancedRateLimitRequests safely sets the value for global configuration 'AdvancedRateLimitRequests' field func SetAdvancedRateLimitRequests(v int) { global.SetAdvancedRateLimitRequests(v) } +// GetAdvancedThrottlingMultiplier safely fetches the Configuration value for state's 'AdvancedThrottlingMultiplier' field +func (st *ConfigState) GetAdvancedThrottlingMultiplier() (v int) { + st.mutex.Lock() + v = st.config.AdvancedThrottlingMultiplier + st.mutex.Unlock() + return +} + +// SetAdvancedThrottlingMultiplier safely sets the Configuration value for state's 'AdvancedThrottlingMultiplier' field +func (st *ConfigState) SetAdvancedThrottlingMultiplier(v int) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.AdvancedThrottlingMultiplier = v + st.reloadToViper() +} + +// AdvancedThrottlingMultiplierFlag returns the flag name for the 'AdvancedThrottlingMultiplier' field +func AdvancedThrottlingMultiplierFlag() string { return "advanced-throttling-multiplier" } + +// GetAdvancedThrottlingMultiplier safely fetches the value for global configuration 'AdvancedThrottlingMultiplier' field +func GetAdvancedThrottlingMultiplier() int { return global.GetAdvancedThrottlingMultiplier() } + +// SetAdvancedThrottlingMultiplier safely sets the value for global configuration 'AdvancedThrottlingMultiplier' field +func SetAdvancedThrottlingMultiplier(v int) { global.SetAdvancedThrottlingMultiplier(v) } + // GetCacheGTSAccountMaxSize safely fetches the Configuration value for state's 'Cache.GTS.AccountMaxSize' field func (st *ConfigState) GetCacheGTSAccountMaxSize() (v int) { st.mutex.Lock() diff --git a/internal/middleware/throttling.go b/internal/middleware/throttling.go new file mode 100644 index 000000000..0f98387dd --- /dev/null +++ b/internal/middleware/throttling.go @@ -0,0 +1,153 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +/* + The code in this file is adapted from MIT-licensed code in github.com/go-chi/chi. Thanks chi (thi)! + + See: https://github.com/go-chi/chi/blob/e6baba61759b26ddf7b14d1e02d1da81a4d76c08/middleware/throttle.go + + And: https://github.com/sponsors/pkieltyka +*/ + +package middleware + +import ( + "net/http" + "runtime" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + errCapacityExceeded = "server capacity exceeded" + errTimedOut = "timed out while waiting for a pending request to complete" + errContextCanceled = "context canceled" +) + +// token represents a request that is being processed. +type token struct{} + +// Throttle returns a gin middleware that performs throttling of incoming requests, +// ensuring that only a certain number of requests are handled concurrently, to reduce +// congestion of the server. +// +// Limits are configured using available CPUs and the given cpuMultiplier value. +// Open request limit is available CPUs * multiplier; backlog limit is limit * multiplier. +// +// Example values for multiplier 8: +// +// 1 cpu = 08 open, 064 backlog +// 2 cpu = 16 open, 128 backlog +// 4 cpu = 32 open, 256 backlog +// +// Example values for multiplier 4: +// +// 1 cpu = 04 open, 016 backlog +// 2 cpu = 08 open, 032 backlog +// 4 cpu = 16 open, 064 backlog +// +// Callers will first attempt to get a backlog token. Once they have that, they will +// wait in the backlog queue until they can get a token to allow their request to be +// processed. +// +// If the backlog queue is full, the request context is closed, or the caller has been +// waiting in the backlog for too long, this function will abort the request chain, +// write a JSON error into the response, set an appropriate Retry-After value, and set +// the HTTP response code to 503: Service Unavailable. +// +// If the multiplier is <= 0, a noop middleware will be returned instead. +// +// Useful links: +// +// - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After +// - https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503 +func Throttle(cpuMultiplier int) gin.HandlerFunc { + if cpuMultiplier <= 0 { + // throttling is disabled, return a noop middleware + return func(c *gin.Context) {} + } + + var ( + limit = runtime.GOMAXPROCS(0) * cpuMultiplier + backlogLimit = limit * cpuMultiplier + backlogChannelSize = limit + backlogLimit + tokens = make(chan token, limit) + backlogTokens = make(chan token, backlogChannelSize) + retryAfter = "30" // seconds + backlogDuration = 30 * time.Second + ) + + // prefill token channels + for i := 0; i < limit; i++ { + tokens <- token{} + } + + for i := 0; i < backlogChannelSize; i++ { + backlogTokens <- token{} + } + + // bail instructs the requester to return after retryAfter seconds, returns a 503, + // and writes the given message into the "error" field of a returned json object + bail := func(c *gin.Context, msg string) { + c.Header("Retry-After", retryAfter) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": msg}) + c.Abort() + } + + return func(c *gin.Context) { + // inside this select, the caller tries to get a backlog token + select { + case <-c.Request.Context().Done(): + // request context has been canceled already + bail(c, errContextCanceled) + case btok := <-backlogTokens: + // take a backlog token and wait + timer := time.NewTimer(backlogDuration) + defer func() { + // when we're finished, return the backlog token to the bucket + backlogTokens <- btok + }() + + // inside *this* select, the caller has a backlog token, + // and they're waiting for their turn to be processed + select { + case <-timer.C: + // waiting too long in the backlog + bail(c, errTimedOut) + case <-c.Request.Context().Done(): + // the request context has been canceled already + timer.Stop() + bail(c, errContextCanceled) + case tok := <-tokens: + // the caller gets a token, so their request can now be processed + timer.Stop() + defer func() { + // whatever happens to the request, put the + // token back in the bucket when we're finished + tokens <- tok + }() + c.Next() // <- finally process the caller's request + } + + default: + // we don't have space in the backlog queue + bail(c, errCapacityExceeded) + } + } +} |