Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-11 00:29:15 -05:00
pkg-proxy/internal/httpclient/ratelimit_test.go
Giles Westwood b2955ef458 Keep holds through late successes and away from the circuit breaker
A response arriving during a hold was sent before it began, so a late
success no longer releases the host; only one after the hold has ended
does.

Artifact fetches check RateLimitGate.Held before calling the fetcher, so
held cache misses fail with the HeldError without counting as failures
in the host's circuit breaker.

Every handler that answers an upstream fetch error with 502 now answers
a held request with 503 and Retry-After instead.

OnHold and RecordUpstreamRateLimitHold drop the unused duration.
2026-10-15 13:35:12 +01:00

257 lines
8.7 KiB
Go

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)
}
}
}