Throttled: Guardian Of The Web Server
I just put the finishing touches for the release of throttled, a Go package that implements various strategies to control access to HTTP handlers. Out-of-the-box, it supports rate-limiting of requests, constant interval flow of requests and memory usage thresholds to grant or deny access, but it also provides mechanisms to extend its functionality.
How It Works
At the heart of the package is the Throttler
structure and the Limiter
interface. The throttler offers a single method, Throttle(h http.Handler) http.Handler
, that wraps the given handler h
and returns a new handler that throttles access to h
. How it does the throttling is up to the Limiter
.
The Limiter
interface is defined as follows:
type Limiter interface {
Start()
Limit(http.ResponseWriter, *http.Request) (<-chan bool, error)
}
The Start
method tells the limiter to get to work, initializing any internal state as needed, and Limit
does the actual work of applying the limiter’s strategy for this specific request. It returns a receive-only channel of boolean that will indicate to the Throttler
if it should allow or deny the request (if the channel returns true or false). It may also return an error, in which case the throttler will call the function assigned to the package-level Error
variable.
When a request is granted access, the wrapped handler is called. But what happens when the access is denied? Well, there is a package-level DefaultDeniedHandler
that can be used. By default it returns a status 429 with a generic message, but it is a humble http.Handler
and can be set to do whatever needs to be done.
But since some throttlers may require specific handling of those requests, there is also a DeniedHandler
field on the Throttler
struct. If it is nil, the package-level DefaultDeniedHandler
is used, otherwise the throttler-specific handler is called.
What It Does
The package usage revolves around three main functions: Interval
, RateLimit
and MemStats
. There’s also Custom
, but this is a hook for extensibility, it doesn’t do anything special on its own.
Interval
Interval(delay Delayer, bursts int, vary *VaryBy, maxKeys int) *Throttler
As the name implies, this function allows requests to proceed at a given constant interval. The Delayer
interface specifies this interval:
type Delayer interface {
Delay() time.Duration
}
With this interface in place, it is possible to set intervals in expressive ways, using the PerSec
, PerMin
, PerHour
or PerDay
types, or the D
type, which is simply a time.Duration
that fulfills the Delayer
interface by returning its own value.
An example would surely help:
// Allow 10 requests per second, or one each 100ms
Interval(PerSec(10), 0, nil, 0)
// Allow 30 requests per minute, or one each 2s
Interval(PerMin(30), 0, nil, 0)
// Allow one request each 7 minute
Interval(D(7*time.Minute), 0, nil, 0)
What look like function calls are actually conversions of integers - or time.Duration in the last case - to the specific type that fulfills the Delayer
interface.
Back to the Interval
function signature, the bursts
argument sets how many exceeding requests can be queued to proceed when their time comes. vary
tells the throttler to apply the interval separately based on some criteria on the request. The VaryBy
struct is defined like this:
type VaryBy struct {
// Vary by the RemoteAddr as specified by the net/http.Request field.
RemoteAddr bool
// Vary by the HTTP Method as specified by the net/http.Request field.
Method bool
// Vary by the URL's Path as specified by the Path field of the net/http.Request
// URL field.
Path bool
// Vary by this list of header names, read from the net/http.Request Header field.
Headers []string
// Vary by this list of parameters, read from the net/http.Request FormValue method.
Params []string
// Vary by this list of cookie names, read from the net/http.Request Cookie method.
Cookies []string
// Use this separator string to concatenate the various criteria of the VaryBy struct.
// Defaults to a newline character if empty (\n).
Separator string
}
Finally, the maxKeys
argument sets the maximum number of vary-by keys to keep in memory, using an LRU cache (because internally each vary-by key gets its own channel and goroutine to control the flow).
Using siege
and the example applications in the /examples/ subdirectory of the repository, let’s see the interval throttler in action:
# Run the example app (in examples/interval-vary/)
$ ./interval-vary -delay 100ms -bursts 100 -output ok
# In another terminal window, start siege with the URL file to hit various URLs
$ siege -b -f siege-urls
# Output from the example app:
2014/02/18 17:23:47 /a: ok: 1.050021944s
2014/02/18 17:23:47 /b: ok: 1.050646811s
2014/02/18 17:23:47 /c: ok: 1.051085882s
2014/02/18 17:23:47 /a: ok: 1.15102831s
2014/02/18 17:23:47 /b: ok: 1.151841098s
2014/02/18 17:23:47 /c: ok: 1.152307554s
2014/02/18 17:23:47 /a: ok: 1.252140208s
2014/02/18 17:23:47 /b: ok: 1.253117856s
2014/02/18 17:23:47 /c: ok: 1.253621192s
Each path receives requests at 100ms intervals.
RateLimit
RateLimit(quota Quota, vary *VaryBy, store Store) *Throttler
This function creates a throttler that limits the number of requests allowed in a time window, which is a very common requirement in public RESTful APIs.
The Quota interface defines a single method:
type Quota interface {
Quota() (int, time.Duration)
}
It returns the number of requests and the duration of the time window. Conveniently, the PerXxx
types that implement the Delayer
interface also implement the Quota
interface, and there is a Q
type to define custom quotas. Again, examples help:
// Allow 10 requests per second
RateLimit(PerSec(10), &VaryBy{RemoteAddr: true}, store.NewMemStore(0))
// Allow 30 requests per minute
RateLimit(PerMin(30), &VaryBy{RemoteAddr: true}, store.NewMemStore(0))
// Allow 15 requests each 30 minute
RateLimit(Q{15, 30*time.Minute}, &VaryBy{RemoteAddr: true}, store.NewMemStore(0))
The vary
argument plays the same role as in Interval
. The store
is used to save the rate-limiting state. The Store
interface is:
type Store interface {
// Incr increments the count for the specified key and returns the new value along
// with the number of seconds remaining. It may return an error
// if the operation fails.
Incr(key string, window time.Duration) (cnt int, secs int, e error)
// Reset resets the key to 1 with the specified window duration. It must create the
// key if it doesn't exist. It returns an error if it fails.
Reset(key string, window time.Duration) error
}
The throttled/store
package offers an in-memory store and a Redis-based store.
The rate-limiter automatically adds the X-RateLimit-Limit
, X-RateLimit-Remaining
and X-RateLimit-Reset
headers on the response. The Limit indicates the number of requests allowed in the window, Remaining is the number of requests remaining in the current window, and Reset indicates the number of seconds remaining until the end of the current window.
When the limit is busted, the header Retry-After
is also added to the response, with the same value as the Reset header.
Using curl
and the example app, let’s see it in action:
# Run the example app (in examples/rate-limit/)
$ ./rate-limit -requests 3 -window 30s
# Run curl the first time
$ curl -i http://localhost:9000/a
HTTP/1.1 200 OK
X-Ratelimit-Limit: 3
X-Ratelimit-Remaining: 2
X-Ratelimit-Reset: 29
Date: Wed, 19 Feb 2014 00:59:15 GMT
Content-Length: 0
Content-Type: text/plain; charset=utf-8
# ... (skipped 2nd and 3rd) run curl a fourth time
$ curl -i http://localhost:9000/a
HTTP/1.1 429 Too Many Requests
Content-Type: text/plain; charset=utf-8
Retry-After: 23
X-Ratelimit-Limit: 3
X-Ratelimit-Remaining: 0
X-Ratelimit-Reset: 23
Date: Wed, 19 Feb 2014 00:59:22 GMT
Content-Length: 15
limit exceeded
MemStats
MemStats(thresholds *runtime.MemStats, refreshRate time.Duration) *Throttler
This function accepts a struct of memory stats with the desired thresholds, and a refresh rate indicating when to refresh the current memory stats values (0 means read on each request). Any integer field in the MemStats struct can be used as a threshold value, and zero-valued fields are ignored.
The thresholds must be in absolute value (i.e. Allocs = 10 000 means 10 000 bytes allocated by the process, not 10 000 bytes more than some previous reading), but there is the helper function MemThresholds(offsets *runtime.MemStats) *runtime.MemStats
that translates offsets to absolute values.
Using boom
(a nice Go load generator) and the memstats example app (that fully loads in memory a 64Kb file on each request), we can test its behaviour:
# Run the example app (in examples/memstats/)
$ ./memstats -total 500000 -output ok
# Run boom
$ boom -n 100 -c 10 http://localhost:9000
# Example app output
2014/02/18 20:06:17 ok: 1.722598952s
2014/02/18 20:06:17 ok: 1.722931271s
2014/02/18 20:06:17 ok: 1.72315662s
2014/02/18 20:06:17 ok: 1.723366605s
2014/02/18 20:06:26 ok: 4, ko: 96
2014/02/18 20:06:26 TotalAllocs: 1309 Kb, Allocs: 1197 Kb, Mallocs: 2833, NumGC: 4
Obviously, some memory stats just go up and never down, so once the threshold is reached, no other request will ever be allowed. But since the DeniedHandler is just a Handler, it is possible to build a routing strategy such that once the threshold is reached, requests are sent to a throttled handler that allows requests to go through at a slow interval, for example, or a handler that restarts the process, whatever’s required!
Custom
Custom(l Limiter) *Throttler
A quick word on the Custom function, it accepts any Limiter
as argument and returns a throttler that uses this limiter. There is an example of a custom limiter in the /examples/custom/ subdirectory.
Miscellaneous Closing Thoughts
As alluded to in the MemStats section, the package manipulates plain old HTTP handlers, so combining them in useful and creative ways is definitely possible. The DeniedHandler is just that, a Handler, it doesn’t have to return a 429 or 503 error, it can do whatever is needed to do, like call a differently throttled (or non-throttled) handler.
The example apps are useful for testing with the data race detector in real-world (or not-so-real-world) usage. Just
go build -race
the app, and see how it goes.Inspired by Go’s pre-commit hook example in its misc/git/ folder, I added a pre-commit hook in my Go repositories that run both
golint
andgo vet
on my packages. Both programs have proved very helpful in finding different categories of bugs (vet) and consistency/style errors (lint). You can find my hook in the /misc/ subdirectory of the repository. Note that the linter output is purely informational, it doesn’t return an exit code != 0 and thus does not prevent the commit from happening if it finds some problems.
That’s it for now, hope you like it! Fork, star, test, report issues and PR at will!