Skip to content

Commit bd600e4

Browse files
authored
Merge pull request #102 from fastly/joeshaw/erl
2 parents 579dadc + 0fb67bb commit bd600e4

File tree

6 files changed

+612
-0
lines changed

6 files changed

+612
-0
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 1.3.0 (2024-02-21)
2+
3+
### Added
4+
5+
- Add support for edge rate limiting (`erl`)
6+
17
## 1.2.1 (2024-01-19)
28

39
### Added

erl/erl.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// Package erl provides Edge Rate Limiting functionality.
2+
//
3+
// This package includes a [RateCounter] type that can be used to
4+
// increment an event counter, and to examine the rate of events per
5+
// second within a POP over 1, 10, and 60 second windows. It can also
6+
// estimate the number of events seen over the past minute within a POP
7+
// in 10 second buckets.
8+
//
9+
// The [PenaltyBox] type can be used to track entries that should be
10+
// penalized for a certain amount of time.
11+
//
12+
// The [RateLimiter] type combines a rate counter and a penalty box to
13+
// determine whether a given entry should be rate limited based on
14+
// whether it exceeds a maximum threshold of events per second over a
15+
// given rate window. Most users can simply use [RateLimiter.CheckRate]
16+
// rather than methods on [RateCounter] and [PenaltyBox].
17+
//
18+
// Rate counters and penalty boxes are combined and synchronized within
19+
// a POP. However, Edge Rate Limiting is not intended to compute counts
20+
// or rates with high precision and may under count by up to 10%.
21+
//
22+
// Both rate counters and penalty boxes have a fixed capacity for
23+
// entries. Once a rate counter is full, each new entry evicts the
24+
// entry that was least recently incremented. Once a penalty box is
25+
// full, each new entry will evict the entry with the shortest remaining
26+
// time-to-live (TTL).
27+
package erl
28+
29+
import (
30+
"errors"
31+
"fmt"
32+
"time"
33+
34+
"github.com/fastly/compute-sdk-go/internal/abi/fastly"
35+
)
36+
37+
var (
38+
// ErrInvalidArgument indicates that an invalid argument was passed
39+
// to one of the edge rate limiter methods.
40+
//
41+
// Most functions and methods have limited ranges for their
42+
// parameters. See the documentation for each call for more
43+
// details.
44+
ErrInvalidArgument = errors.New("invalid argument")
45+
46+
// ErrUnexpected indicates that an unexpected error occurred.
47+
ErrUnexpected = errors.New("unexpected error")
48+
)
49+
50+
var (
51+
// RateWindow1s incidates the rate of events per second over the past
52+
// second.
53+
RateWindow1s = fastly.RateWindow1s
54+
55+
// RateWindow10s indicates the rate of events per second over the
56+
// past 10 seconds.
57+
RateWindow10s = fastly.RateWindow10s
58+
59+
// RateWindow60s indicates the rate of events per second over the
60+
// past 60 seconds.
61+
RateWindow60s = fastly.RateWindow60s
62+
)
63+
64+
var (
65+
// CounterDuration10s indicates the estimated number of events in
66+
// the most recent 10 second bucket.
67+
CounterDuration10s = fastly.CounterDuration10s
68+
69+
// CounterDuration20s indicates the estimated number of events in
70+
// the most recent two 10 second buckets.
71+
CounterDuration20s = fastly.CounterDuration20s
72+
73+
// CounterDuration30s indicates the estimated number of events in
74+
// the most recent three 10 second buckets.
75+
CounterDuration30s = fastly.CounterDuration30s
76+
// CounterDuration40s indicates the estimated number of events in
77+
// the most recent four 10 second buckets.
78+
CounterDuration40s = fastly.CounterDuration40s
79+
80+
// CounterDuration50s indicates the estimated number of events in
81+
// the most recent five 10 second buckets.
82+
CounterDuration50s = fastly.CounterDuration50s
83+
84+
// CounterDuration60s indicates the estimated number of events in
85+
// the most recent six 10 second buckets.
86+
CounterDuration60s = fastly.CounterDuration60s
87+
)
88+
89+
type (
90+
// RateWindow indicates the rate of events per second in the current
91+
// POP over one of a few predefined time windows. See
92+
// [RateWindow1s], [RateWindow10s], and [RateWindow60s].
93+
RateWindow = fastly.RateWindow
94+
95+
// CounterDuration indicates the estimated number of events in this
96+
// duration in the current POP. Counts are divided into 10 second
97+
// buckets, and each bucket represents the estimated number of
98+
// requests received up to and including that 10 second window.
99+
//
100+
// Buckets are not continuous. For example, if the current time is
101+
// 12:01:03, then the 10 second bucket represents events received
102+
// between 12:01:00 and 12:01:10, not between 12:00:53 and 12:01:03.
103+
// This means that, in each minute at the ten second mark (:00, :10,
104+
// :20, etc.) the window represented by each bucket will shift.
105+
//
106+
// Estimated counts are not precise and should not be used as
107+
// counters.
108+
//
109+
// See [CounterDuration10s], [CounterDuration20s],
110+
// [CounterDuration30s], [CounterDuration40s], [CounterDuration50s],
111+
// and [CounterDuration60s].
112+
CounterDuration = fastly.CounterDuration
113+
)
114+
115+
// RateCounter is a named counter that can be incremented and queried.
116+
type RateCounter struct {
117+
name string
118+
}
119+
120+
// OpenRateCounter opens a rate counter with the given name, creating it
121+
// if it doesn't already exist. The rate counter name may be up to 64
122+
// characters long. Entry names in this counter are also limited to 64
123+
// characters.
124+
func OpenRateCounter(name string) *RateCounter {
125+
return &RateCounter{name: name}
126+
}
127+
128+
// Increment increments the rate counter for this entry by the given
129+
// delta value. The minimum value is 0 and the maximum is 1000.
130+
func (rc *RateCounter) Increment(entry string, delta uint32) error {
131+
return mapFastlyError(fastly.RateCounterIncrement(rc.name, entry, delta))
132+
}
133+
134+
// LookupRate returns the rate of events per second over the given rate
135+
// window for this entry.
136+
func (rc *RateCounter) LookupRate(entry string, window RateWindow) (uint32, error) {
137+
v, err := fastly.RateCounterLookupRate(rc.name, entry, window)
138+
if err != nil {
139+
return 0, mapFastlyError(err)
140+
}
141+
return v, nil
142+
}
143+
144+
// LookupCount returns the estimated number of events in the given
145+
// duration for this entry. The duration represents a discrete window,
146+
// not a continuous one. See [CounterDuration] for more details.
147+
func (rc *RateCounter) LookupCount(entry string, duration CounterDuration) (uint32, error) {
148+
v, err := fastly.RateCounterLookupCount(rc.name, entry, duration)
149+
if err != nil {
150+
return 0, mapFastlyError(err)
151+
}
152+
return v, nil
153+
}
154+
155+
// PenaltyBox is a type that allows entries to be penalized for a given
156+
// number of minutes in the future.
157+
type PenaltyBox struct {
158+
name string
159+
}
160+
161+
// OpenPenaltyBox opens a penalty box with the given name, creating it
162+
// if it doesn't already exist. The penalty box name may be up to 64
163+
// characters long. Entry names in this penalty box are also limited to
164+
// 64 characters.
165+
func OpenPenaltyBox(name string) *PenaltyBox {
166+
return &PenaltyBox{name: name}
167+
}
168+
169+
// Add adds an entry to the penalty box for the given time-to-live (TTL)
170+
// duration. The minimum value is 1 minute and the maximum is 60
171+
// minutes. If an entry is already in the penalty box, its TTL is
172+
// replaced with the new value. Entries are automatically evicted from
173+
// the penalty box when the TTL expires.
174+
func (pb *PenaltyBox) Add(entry string, ttl time.Duration) error {
175+
return mapFastlyError(fastly.PenaltyBoxAdd(pb.name, entry, ttl))
176+
}
177+
178+
// Has returns true if the given entry is currently in the penalty box.
179+
func (pb *PenaltyBox) Has(entry string) (bool, error) {
180+
ok, err := fastly.PenaltyBoxHas(pb.name, entry)
181+
if err != nil {
182+
return false, mapFastlyError(err)
183+
}
184+
return ok, nil
185+
}
186+
187+
// Policy contains the rules for applying a [RateLimiter].
188+
type Policy struct {
189+
// RateWindow is the window of time to consider when checking the
190+
// rate of events per second.
191+
RateWindow RateWindow
192+
193+
// MaxRate is the maximum number of events per second to allow over
194+
// the rate window. The minimum value is 10 and the maximum is
195+
// 10000.
196+
MaxRate uint32
197+
198+
// PenaltyBoxDuration is the duration to penalize entries that
199+
// exceed the maximum rate. As with PenaltyBox.Add, the minimum
200+
// value is 1 minute and the maximum is 60 minutes.
201+
PenaltyBoxDuration time.Duration
202+
}
203+
204+
// RateLimiter combines a [RateCounter] and a [PenaltyBox] to provide an
205+
// easy way to check whether a given entry should be rate limited given
206+
// a rate window and upper limit.
207+
type RateLimiter struct {
208+
RateCounter *RateCounter
209+
PenaltyBox *PenaltyBox
210+
}
211+
212+
// NewRateLimiter creates a new rate limiter.
213+
func NewRateLimiter(rc *RateCounter, pb *PenaltyBox) *RateLimiter {
214+
return &RateLimiter{
215+
RateCounter: rc,
216+
PenaltyBox: pb,
217+
}
218+
}
219+
220+
// CheckRate increments an entry's rate counter by the delta value
221+
// ([RateCounter.Increment]), and checks it against the provided
222+
// [Policy]. If the count after increment exceeds the policy's MaxRate
223+
// over the RateWindow, it will add the entry to the penalty box for the
224+
// policy's PenaltyBoxDuration. It returns true if the entry is in the
225+
// penalty box.
226+
//
227+
// The limits for the delta value are the same as
228+
// [RateCounter.Increment].
229+
func (erl *RateLimiter) CheckRate(entry string, delta uint32, policy *Policy) (bool, error) {
230+
blocked, err := fastly.ERLCheckRate(
231+
erl.RateCounter.name,
232+
entry,
233+
delta,
234+
policy.RateWindow,
235+
policy.MaxRate,
236+
erl.PenaltyBox.name,
237+
policy.PenaltyBoxDuration,
238+
)
239+
if err != nil {
240+
return false, mapFastlyError(err)
241+
}
242+
return blocked, nil
243+
}
244+
245+
func mapFastlyError(err error) error {
246+
status, ok := fastly.IsFastlyError(err)
247+
if !ok {
248+
return err
249+
}
250+
251+
switch status {
252+
case fastly.FastlyStatusInval:
253+
return ErrInvalidArgument
254+
default:
255+
return fmt.Errorf("%w (%s)", ErrUnexpected, status)
256+
}
257+
}

erl/erl_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package erl_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/fastly/compute-sdk-go/erl"
9+
"github.com/fastly/compute-sdk-go/fsthttp"
10+
)
11+
12+
func ExampleRateLimiter_CheckRate() {
13+
fsthttp.ServeFunc(func(ctx context.Context, w fsthttp.ResponseWriter, r *fsthttp.Request) {
14+
limiter := erl.NewRateLimiter(
15+
erl.OpenRateCounter("requests"),
16+
erl.OpenPenaltyBox("bad_ips"),
17+
)
18+
19+
block, err := limiter.CheckRate(
20+
r.RemoteAddr, // Use the IP address of the client as the entry
21+
1, // Increment the request counter by 1
22+
&erl.Policy{
23+
erl.RateWindow10s, // Check the rate of requests per second over the past 10 seconds
24+
100, // Allow up to 100 requests per second
25+
time.Minute, // Put offenders into the penalty box for 1 minute
26+
},
27+
)
28+
if err != nil {
29+
// It's probably better to fail open. Consider logging the
30+
// error but continuing to handle the request.
31+
} else if block {
32+
// The rate limit has been exceeded. Return a 429 Too Many
33+
// Requests response.
34+
w.WriteHeader(fsthttp.StatusTooManyRequests)
35+
return
36+
}
37+
38+
// Otherwise, continue processing the request.
39+
})
40+
}
41+
42+
func ExampleRateCounter_LookupRate() {
43+
fsthttp.ServeFunc(func(ctx context.Context, w fsthttp.ResponseWriter, r *fsthttp.Request) {
44+
rc := erl.OpenRateCounter("requests")
45+
46+
// Increment the request counter by 1
47+
rc.Increment(r.RemoteAddr, 1)
48+
49+
// Get the current rate of requests per second over the past 60
50+
// seconds
51+
rate, err := rc.LookupRate(r.RemoteAddr, erl.RateWindow60s)
52+
if err != nil {
53+
w.WriteHeader(fsthttp.StatusInternalServerError)
54+
return
55+
}
56+
57+
fmt.Fprintf(w, "Rate over the past 60 seconds: %d requests per second\n", rate)
58+
})
59+
}
60+
61+
func ExampleRateCounter_LookupCount() {
62+
fsthttp.ServeFunc(func(ctx context.Context, w fsthttp.ResponseWriter, r *fsthttp.Request) {
63+
rc := erl.OpenRateCounter("requests")
64+
65+
// Increment the request counter by 1
66+
rc.Increment(r.RemoteAddr, 1)
67+
68+
// Get an estimated count of total number of requests over the
69+
// past 60 seconds
70+
count, err := rc.LookupCount(r.RemoteAddr, erl.CounterDuration60s)
71+
if err != nil {
72+
w.WriteHeader(fsthttp.StatusInternalServerError)
73+
return
74+
}
75+
76+
fmt.Fprintf(w, "Estimated count over the past 60 seconds: %d requests\n", count)
77+
})
78+
}

0 commit comments

Comments
 (0)