Skip to content

Commit 74f9e3a

Browse files
authored
Introducing RateLimiter to Control Request Rate (Issue #4) (#5)
* Introducing RateLimiter to Control Request Rate #4 * Updating README to describe how-to use rate limiting * RateLimiting to RateLimit
1 parent 0d13380 commit 74f9e3a

File tree

5 files changed

+164
-1
lines changed

5 files changed

+164
-1
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import "github.com/txpull/sourcify-go"
2424

2525
### Creating a Client
2626

27-
To interact with the Sourcify API, you need to create a client using the `NewClient` function. You can provide optional configuration options using `ClientOption` functions. For example, you can specify a custom base URL or a custom HTTP client and set retry configuration in case sourcify servers are temporairly unavailable.
27+
To interact with the Sourcify API, you need to create a client using the `NewClient` function. You can provide optional configuration options using `ClientOption` functions. For example, you can specify a custom base URL or a custom HTTP client, set retry configuration in case sourcify servers are temporairly unavailable and set rate limits in order to control the rate at which HTTP requests are sent to the sourcify servers.
2828

2929
```go
3030
client := sourcify.NewClient(
@@ -34,6 +34,7 @@ client := sourcify.NewClient(
3434
sourcify.WithMaxRetries(3),
3535
sourcify.WithDelay(2*time.Second),
3636
),
37+
sourcify.WithRateLimit(10, 1*time.Second),
3738
)
3839
```
3940

client.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type Client struct {
3737
BaseURL string // The base URL of the Sourcify API.
3838
HTTPClient *http.Client // The HTTP client to use for making requests.
3939
RetryOptions RetryOptions // The retry options for the client.
40+
RateLimiter *RateLimiter // The rate limiter for the client.
4041
}
4142

4243
// WithHTTPClient allows you to provide your own http.Client for the Sourcify client.
@@ -62,6 +63,13 @@ func WithRetryOptions(options ...RetryOption) ClientOption {
6263
}
6364
}
6465

66+
// WithRateLimit allows you to configure rate limits for the Sourcify client.
67+
func WithRateLimit(max int, duration time.Duration) ClientOption {
68+
return func(c *Client) {
69+
c.RateLimiter = NewRateLimiter(max, duration)
70+
}
71+
}
72+
6573
// NewClient initializes a new Sourcify client with optional configurations.
6674
// By default, it uses the Sourcify API's base URL (https://sourcify.dev/server),
6775
// the default http.Client, and no retry options.
@@ -150,6 +158,10 @@ func (c *Client) doRequestWithRetry(req *http.Request) (io.ReadCloser, int, erro
150158
attempt := 0
151159

152160
for {
161+
if c.RateLimiter != nil {
162+
c.RateLimiter.Wait()
163+
}
164+
153165
attempt++
154166
resp, err := c.HTTPClient.Do(req)
155167
if err != nil {

client_rate_limiter.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package sourcify
2+
3+
import "time"
4+
5+
// RateLimiter represents a rate limiter that controls the rate of actions using the token bucket algorithm.
6+
// It provides a mechanism to prevent an HTTP client from exceeding a certain rate of requests.
7+
// The Max field represents the maximum number of actions that can be performed per 'Duration'.
8+
// The Duration field represents the time duration for which 'Max' number of actions can be performed.
9+
// These fields together determine the capacity of the token bucket and the rate at which tokens are added to the bucket.
10+
// The bucket field is a channel that models the token bucket. A token is consumed from the bucket each time an action is taken.
11+
// The capacity of the bucket determines the maximum burstiness of the actions, while the rate at which tokens are added
12+
// to the bucket determines the sustainable average rate of actions.
13+
type RateLimiter struct {
14+
// Max is the maximum number of actions that can be performed per 'Duration'.
15+
Max int
16+
// Duration is the time duration for which 'Max' number of actions can be performed.
17+
Duration time.Duration
18+
// bucket is a channel that models the token bucket. A token is consumed from the bucket each time an action is taken.
19+
bucket chan struct{}
20+
}
21+
22+
// NewRateLimiter creates a new rate limiter.
23+
// The rate limiter uses the token bucket algorithm to control the rate of actions.
24+
// It initially creates a bucket of capacity 'Max' and then adds a token to the bucket every 'Duration'.
25+
// It allows a maximum of 'Max' actions to be performed per 'Duration'.
26+
// If an action is attempted when the bucket is empty, the action blocks until a token is added to the bucket.
27+
// This blocking behaviour ensures that the rate of actions does not exceed the specified rate.
28+
//
29+
// Parameters:
30+
// max - The maximum number of actions that can be performed per 'duration'. It is the capacity of the token bucket.
31+
// duration - The time duration for which 'max' number of actions can be performed.
32+
//
33+
// Returns:
34+
// A pointer to the created RateLimiter.
35+
func NewRateLimiter(max int, duration time.Duration) *RateLimiter {
36+
bucket := make(chan struct{}, max)
37+
38+
// Initially, the bucket is filled to its capacity.
39+
for i := 0; i < max; i++ {
40+
bucket <- struct{}{}
41+
}
42+
43+
// A ticker is set up to add a token to the bucket every 'duration'.
44+
// If the bucket is full, the addition of a new token blocks until there is room in the bucket.
45+
// This ensures that the rate of actions doesn't exceed the specified rate.
46+
go func() {
47+
ticker := time.NewTicker(duration)
48+
for range ticker.C {
49+
bucket <- struct{}{}
50+
}
51+
}()
52+
53+
return &RateLimiter{
54+
Max: max,
55+
Duration: duration,
56+
bucket: bucket,
57+
}
58+
}
59+
60+
// Wait is used to perform an action with rate limiting.
61+
// If the token bucket (i.e., 'bucket' field of RateLimiter) is empty, Wait blocks until a token is added to the bucket.
62+
// If a token is available in the bucket, Wait consumes the token and returns immediately, allowing the action to be performed.
63+
func (r *RateLimiter) Wait() {
64+
<-r.bucket
65+
}

client_rate_limiter_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package sourcify
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestNewRateLimiter(t *testing.T) {
11+
// Create a new rate limiter with max 2 actions per second
12+
rateLimiter := NewRateLimiter(2, time.Second)
13+
14+
assert.NotNil(t, rateLimiter)
15+
assert.Equal(t, 2, rateLimiter.Max)
16+
assert.Equal(t, time.Second, rateLimiter.Duration)
17+
}
18+
19+
func TestRateLimiter_Wait(t *testing.T) {
20+
// Create a new rate limiter with max 1 action per second
21+
rateLimiter := NewRateLimiter(1, time.Second)
22+
23+
// Record the start time
24+
start := time.Now()
25+
26+
// Perform 3 actions
27+
for i := 0; i < 3; i++ {
28+
rateLimiter.Wait()
29+
}
30+
31+
// Record the end time
32+
end := time.Now()
33+
34+
// The duration between start and end should be at least 2 seconds,
35+
// since the rate limiter allows only 1 action per second.
36+
assert.GreaterOrEqual(t, end.Sub(start).Seconds(), 2.0)
37+
}
38+
39+
func TestRateLimiter_Wait_Burst(t *testing.T) {
40+
// Create a new rate limiter with max 5 actions per 100 milliseconds
41+
rateLimiter := NewRateLimiter(5, 100*time.Millisecond)
42+
43+
// Record the start time
44+
start := time.Now()
45+
46+
// Perform 5 actions, should be processed in a burst
47+
for i := 0; i < 5; i++ {
48+
rateLimiter.Wait()
49+
}
50+
51+
// Record the end time
52+
end := time.Now()
53+
54+
// The duration between start and end should be less than 100 milliseconds,
55+
// since all the actions are processed in a burst.
56+
assert.Less(t, end.Sub(start).Seconds(), 0.1)
57+
}

client_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,31 @@ func TestDoRequestWithRetry_SuccessfulRetry(t *testing.T) {
182182
assert.NoError(t, err)
183183
assert.Equal(t, "Hello, world!", string(body))
184184
}
185+
186+
func TestWithRateLimiting(t *testing.T) {
187+
client := NewClient(WithRateLimit(10, 1*time.Second))
188+
189+
assert.NotNil(t, client.RateLimiter)
190+
assert.Equal(t, 10, client.RateLimiter.Max)
191+
assert.Equal(t, 1*time.Second, client.RateLimiter.Duration)
192+
}
193+
194+
func TestRateLimiting(t *testing.T) {
195+
handler := func(w http.ResponseWriter, r *http.Request) {
196+
fmt.Fprint(w, "Hello, world!")
197+
}
198+
server := httptest.NewServer(http.HandlerFunc(handler))
199+
defer server.Close()
200+
201+
client := NewClient(
202+
WithBaseURL(server.URL),
203+
WithRateLimit(1, 1*time.Second),
204+
)
205+
206+
req, _ := http.NewRequest("GET", server.URL, nil)
207+
208+
// Perform first request - should pass
209+
resp, _, err := client.doRequestWithRetry(req)
210+
assert.NoError(t, err)
211+
assert.NotNil(t, resp)
212+
}

0 commit comments

Comments
 (0)