Skip to content

Commit

Permalink
httputil: create package and RateLimiter
Browse files Browse the repository at this point in the history
This change adds a rate limiter for HTTP requests. It's purposefully
unconfigurable for ease of use.

Signed-off-by: Hank Donnay <hdonnay@redhat.com>
  • Loading branch information
hdonnay committed May 12, 2021
1 parent bc2b059 commit ed8ffc5
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 3 deletions.
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ require (
go.opentelemetry.io/otel/sdk v0.16.0
golang.org/x/mod v0.4.2 // indirect
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a
golang.org/x/sys v0.0.0-20210317225723-c4fcb01b228e // indirect
golang.org/x/sys v0.0.0-20210122093101-04d7465088b8 // indirect
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba
golang.org/x/tools v0.1.0 // indirect
gopkg.in/square/go-jose.v2 v2.4.1
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -897,8 +897,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210317225723-c4fcb01b228e h1:XNp2Flc/1eWQGk5BLzqTAN7fQIwIbfyVTuVxXxZh73M=
golang.org/x/sys v0.0.0-20210317225723-c4fcb01b228e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210122093101-04d7465088b8 h1:de2yTH1xuxjmGB7i6Z5o2z3RCHVa0XlpSZzjd8Fe6bE=
golang.org/x/sys v0.0.0-20210122093101-04d7465088b8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
Expand Down
67 changes: 67 additions & 0 deletions internal/httputil/ratelimiter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package httputil

import (
"net/http"
"sync"

"golang.org/x/time/rate"
)

// RateLimiter wraps the provided RoundTripper with a limiter allowing 10
// requests/second/host.
//
// It responds to HTTP 429 responses by automatically decreasing the rate.
func RateLimiter(next http.RoundTripper) http.RoundTripper {
return &ratelimiter{
rt: next,
lm: sync.Map{},
}
}

// Ratelimiter implements the limiting by using a concurrent map and Limiter
// structs.
type ratelimiter struct {
rt http.RoundTripper
lm sync.Map
}

const rateCap = 10

// RoundTrip implements http.RoundTripper.
func (r *ratelimiter) RoundTrip(req *http.Request) (*http.Response, error) {
key := req.URL.Host
li, ok := r.lm.Load(key)
if !ok {
// Limiter allows "rateCap" per sec, one at a time.
l := rate.NewLimiter(rate.Limit(rateCap), 1)
li, _ = r.lm.LoadOrStore(key, l)
}
l := li.(*rate.Limiter)
if err := l.Wait(req.Context()); err != nil {
return nil, err
}
res, err := r.rt.RoundTrip(req)
// This seems to be the contract that http.Transport implements.
if err != nil {
return nil, err
}
switch res.StatusCode {
case http.StatusOK:
// Try increasing on OK.
if lim := l.Limit(); lim < rateCap {
l.SetLimit(lim + 1)
}
case http.StatusTooManyRequests:
// Try to allow some requests, eventually.
l.SetLimit(detune(l.Limit()))
}
return res, nil
}

// Detune reduces the rate.
func detune(in rate.Limit) rate.Limit {
if in <= 1 {
return in / 2
}
return in - 1
}
56 changes: 56 additions & 0 deletions internal/httputil/ratelimiter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package httputil

import (
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)

func TestRate(t *testing.T) {
const nReq = 20

var wg sync.WaitGroup
wg.Add(nReq)
begin := make(chan struct{})
var last struct {
sync.Mutex
t time.Time
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
last.Lock()
last.t = time.Now()
last.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cl := srv.Client()
cl.Transport = RateLimiter(cl.Transport)

for i := 0; i < nReq; i++ {
go func() {
defer wg.Done()
<-begin
res, err := cl.Get(srv.URL)
if err != nil {
t.Error(err)
return
}
res.Body.Close()
}()
}

first := time.Now()
close(begin)
wg.Wait()

t.Logf("begin: %v", first)
t.Logf("end: %v", last.t)
rate := nReq / last.t.Sub(first).Seconds()
t.Logf("rate: %v", rate)

if rate < (rateCap-1) || rate > (rateCap+1) {
t.Error("rate outside acceptable bounds")
}
}

0 comments on commit ed8ffc5

Please sign in to comment.