Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-12 00:59:16 -05:00
pkg-proxy/internal/httpclient/ratelimit_test.go

257 lines
8.7 KiB
Go
Raw Permalink Normal View History

package httpclient
import (
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
)
type gateHarness struct {
gate *RateLimitGate
now time.Time
calls map[string]int
// respond returns the status and Retry-After the fake upstream answers with.
respond func(req *http.Request) (int, string)
}
func newGateHarness() *gateHarness {
h := &gateHarness{
now: time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC),
calls: map[string]int{},
respond: func(*http.Request) (int, string) {
return http.StatusOK, ""
},
}
h.gate = NewRateLimitGate(roundTripperFunc(func(req *http.Request) (*http.Response, error) {
h.calls[req.URL.Host]++
status, retryAfter := h.respond(req)
resp := &http.Response{
StatusCode: status,
Header: http.Header{},
Body: io.NopCloser(strings.NewReader("")),
Request: req,
}
if retryAfter != "" {
resp.Header.Set("Retry-After", retryAfter)
}
return resp, nil
}))
h.gate.now = func() time.Time { return h.now }
return h
}
func (h *gateHarness) get(t *testing.T, rawURL string) (*http.Response, error) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
t.Fatalf("building request: %v", err)
}
resp, err := h.gate.RoundTrip(req)
if resp != nil {
_ = resp.Body.Close()
}
return resp, err
}
func TestRateLimitGate_HoldsHostForRetryAfter(t *testing.T) {
h := newGateHarness()
h.respond = func(*http.Request) (int, string) { return http.StatusTooManyRequests, "785" }
resp, err := h.get(t, "https://repo1.maven.org/maven2/a/b/maven-metadata.xml")
if err != nil || resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("first request: resp=%v err=%v, want the upstream 429 passed through", resp, err)
}
h.respond = func(*http.Request) (int, string) { return http.StatusOK, "" }
_, err = h.get(t, "https://repo1.maven.org/maven2/a/b/b-1.0.jar")
var held *HeldError
if !errors.As(err, &held) || !errors.Is(err, ErrUpstreamHeld) {
t.Fatalf("second request error = %v, want *HeldError", err)
}
if held.Host != "repo1.maven.org" || held.RetryAfter != 785*time.Second {
t.Errorf("held = %+v, want repo1.maven.org for 785s", held)
}
if h.calls["repo1.maven.org"] != 1 {
t.Errorf("upstream calls = %d, want 1: held requests must not reach it", h.calls["repo1.maven.org"])
}
if _, err := h.get(t, "https://plugins.gradle.org/m2/x.pom"); err != nil {
t.Errorf("other host: %v, want unaffected", err)
}
holds := h.gate.Holds()
if until, ok := holds["repo1.maven.org"]; !ok || !until.Equal(h.now.Add(785*time.Second)) {
t.Errorf("Holds() = %v, want repo1.maven.org until now+785s", holds)
}
}
func TestRateLimitGate_ReleasesWhenHoldExpiresAndUpstreamServes(t *testing.T) {
h := newGateHarness()
h.respond = func(*http.Request) (int, string) { return http.StatusTooManyRequests, "60" }
_, _ = h.get(t, "https://repo1.maven.org/x")
h.now = h.now.Add(59 * time.Second)
if _, err := h.get(t, "https://repo1.maven.org/x"); !errors.Is(err, ErrUpstreamHeld) {
t.Fatalf("at 59s: err = %v, want still held", err)
}
h.now = h.now.Add(time.Second)
h.respond = func(*http.Request) (int, string) { return http.StatusOK, "" }
resp, err := h.get(t, "https://repo1.maven.org/x")
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("at 60s: resp=%v err=%v, want the request sent", resp, err)
}
if len(h.gate.Holds()) != 0 {
t.Errorf("Holds() = %v after a success, want empty", h.gate.Holds())
}
}
func TestRateLimitGate_LateSuccessKeepsHold(t *testing.T) {
h := newGateHarness()
// The slow request is in flight when the limited one starts the hold,
// and its 200 arrives afterwards.
h.respond = func(req *http.Request) (int, string) {
if req.URL.Path == "/limited" {
return http.StatusTooManyRequests, "120"
}
_, _ = h.get(t, "https://repo1.maven.org/limited")
return http.StatusOK, ""
}
resp, err := h.get(t, "https://repo1.maven.org/slow")
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("slow request: resp=%v err=%v, want its 200", resp, err)
}
if until, ok := h.gate.Holds()["repo1.maven.org"]; !ok || !until.Equal(h.now.Add(120*time.Second)) {
t.Fatalf("Holds() = %v after a late 200, want repo1.maven.org until now+120s", h.gate.Holds())
}
if _, err := h.get(t, "https://repo1.maven.org/x"); !errors.Is(err, ErrUpstreamHeld) {
t.Errorf("next request err = %v, want held", err)
}
}
func TestRateLimitGate_Held(t *testing.T) {
var nilGate *RateLimitGate
if err := nilGate.Held("https://repo1.maven.org/x"); err != nil {
t.Errorf("nil gate Held() = %v, want nil", err)
}
h := newGateHarness()
if err := h.gate.Held("https://repo1.maven.org/x"); err != nil {
t.Errorf("before any limit: Held() = %v, want nil", err)
}
h.respond = func(*http.Request) (int, string) { return http.StatusTooManyRequests, "45" }
_, _ = h.get(t, "https://repo1.maven.org/x")
var held *HeldError
if err := h.gate.Held("https://REPO1.maven.org/a/b.jar"); !errors.As(err, &held) || held.RetryAfter != 45*time.Second {
t.Errorf("Held() = %v, want *HeldError for 45s", err)
}
if err := h.gate.Held("https://plugins.gradle.org/m2/x.pom"); err != nil {
t.Errorf("other host: Held() = %v, want nil", err)
}
h.now = h.now.Add(45 * time.Second)
if err := h.gate.Held("https://repo1.maven.org/x"); err != nil {
t.Errorf("after the hold: Held() = %v, want nil", err)
}
}
func TestRateLimitGate_DefaultHoldDoublesWithoutHeader(t *testing.T) {
h := newGateHarness()
h.respond = func(*http.Request) (int, string) { return http.StatusTooManyRequests, "" }
wantHolds := []time.Duration{time.Minute, 2 * time.Minute, 4 * time.Minute}
for i, want := range wantHolds {
_, _ = h.get(t, "https://repo1.maven.org/x")
until := h.gate.Holds()["repo1.maven.org"]
if got := until.Sub(h.now); got != want {
t.Errorf("strike %d: hold = %s, want %s", i+1, got, want)
}
h.now = until
}
}
func TestRateLimitGate_ConcurrentLimitedResponsesAreOneStrike(t *testing.T) {
h := newGateHarness()
h.respond = func(*http.Request) (int, string) { return http.StatusTooManyRequests, "" }
// Two requests already in flight when the limit starts both see 429.
// The gate only consults the hold before sending, so drive the base
// directly for the second.
_, _ = h.get(t, "https://repo1.maven.org/x")
req, _ := http.NewRequest(http.MethodGet, "https://repo1.maven.org/y", nil)
h.gate.hold(hostKey(req.URL), "")
if got := h.gate.Holds()["repo1.maven.org"].Sub(h.now); got != time.Minute {
t.Errorf("hold = %s after two concurrent 429s, want the single-strike %s", got, time.Minute)
}
}
func TestRateLimitGate_CapsUpstreamRetryAfter(t *testing.T) {
h := newGateHarness()
h.respond = func(*http.Request) (int, string) { return http.StatusTooManyRequests, "86400" }
_, _ = h.get(t, "https://repo1.maven.org/x")
if got := h.gate.Holds()["repo1.maven.org"].Sub(h.now); got != maxHold {
t.Errorf("hold = %s, want capped at %s", got, maxHold)
}
}
func TestRateLimitGate_ServiceUnavailableNeedsRetryAfter(t *testing.T) {
h := newGateHarness()
h.respond = func(*http.Request) (int, string) { return http.StatusServiceUnavailable, "" }
_, _ = h.get(t, "https://repo1.maven.org/x")
if len(h.gate.Holds()) != 0 {
t.Errorf("503 without Retry-After created hold %v, want none", h.gate.Holds())
}
h.respond = func(*http.Request) (int, string) { return http.StatusServiceUnavailable, "30" }
_, _ = h.get(t, "https://repo1.maven.org/x")
if got := h.gate.Holds()["repo1.maven.org"].Sub(h.now); got != 30*time.Second {
t.Errorf("hold = %s, want 30s from the 503's Retry-After", got)
}
}
func TestRateLimitGate_ReportsHoldsAndNilSafe(t *testing.T) {
var nilGate *RateLimitGate
if holds := nilGate.Holds(); holds != nil {
t.Errorf("nil gate Holds() = %v, want nil", holds)
}
h := newGateHarness()
var reported []string
h.gate.OnHold = func(host string) {
reported = append(reported, host)
}
h.respond = func(*http.Request) (int, string) { return http.StatusTooManyRequests, "10" }
_, _ = h.get(t, "https://repo1.maven.org/x")
if len(reported) != 1 || reported[0] != "repo1.maven.org" {
t.Errorf("OnHold reported %v, want [repo1.maven.org]", reported)
}
}
func TestParseRetryAfter(t *testing.T) {
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
tests := []struct {
value string
want time.Duration
ok bool
}{
{"785", 785 * time.Second, true},
{" 30 ", 30 * time.Second, true},
{"0", 0, false},
{"-5", 0, false},
{"", 0, false},
{"soon", 0, false},
{now.Add(90 * time.Second).Format(http.TimeFormat), 90 * time.Second, true},
{now.Add(-time.Minute).Format(http.TimeFormat), 0, false},
}
for _, tt := range tests {
got, ok := parseRetryAfter(tt.value, now)
if got != tt.want || ok != tt.ok {
t.Errorf("parseRetryAfter(%q) = (%s, %v), want (%s, %v)", tt.value, got, ok, tt.want, tt.ok)
}
}
}