Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 07:39:19 -05:00
pkg-proxy/internal/handler/shared_holds_test.go
Simon Chrzanowski 3935ccd6f7 Share upstream rate-limit holds across instances
Holds placed by the rate limit gate are written to a new upstream_holds
table, and each instance reads that table at most every two seconds and
refuses requests to a host any instance holds. Before, every replica had
to get its own 429 before it stopped sending.

Instances waiting on a fetch lease reread the holds when the fetch fails,
so when the holder was rate limited they answer 503 with its Retry-After
instead of each sending one more request to the limited host.
2026-10-25 17:34:08 +01:00

170 lines
5.7 KiB
Go

package handler
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/database"
upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
"github.com/git-pkgs/proxy/internal/metrics"
registryclient "github.com/git-pkgs/registries/client"
"github.com/git-pkgs/registries/fetch"
"github.com/prometheus/client_golang/prometheus/testutil"
)
// limitedUpstream answers every request with 429 and a Retry-After of ten
// minutes. Requests wait for release, which is open unless a test closes it
// later.
type limitedUpstream struct {
server *httptest.Server
calls atomic.Int32
entered chan struct{}
release chan struct{}
released sync.Once
}
func newLimitedUpstream(t *testing.T, blocked bool) *limitedUpstream {
u := &limitedUpstream{entered: make(chan struct{}, 8), release: make(chan struct{})}
if !blocked {
u.open()
}
t.Cleanup(u.open)
u.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
u.calls.Add(1)
u.entered <- struct{}{}
<-u.release
w.Header().Set("Retry-After", "600")
w.WriteHeader(http.StatusTooManyRequests)
}))
t.Cleanup(u.server.Close)
return u
}
func (u *limitedUpstream) open() { u.released.Do(func() { close(u.release) }) }
func (u *limitedUpstream) artifactURL() string { return u.server.URL + "/left-pad-1.3.0.tgz" }
// useSharedGate routes p's upstream requests through its own rate limit gate,
// sharing holds through db as a replica does.
func useSharedGate(p *Proxy, db *database.DB, u *limitedUpstream) *upstreamhttp.SharedRateLimitGate {
gate := upstreamhttp.NewSharedRateLimitGate(registryclient.NewRateLimitGate(u.server.Client().Transport), db, nil)
client := &http.Client{Transport: gate}
p.HTTPClient = client
p.Fetcher = fetch.NewFetcher(fetch.WithHTTPClient(client), fetch.WithBaseDelay(time.Millisecond))
p.RateLimits = gate
return gate
}
func fetchLimited(ctx context.Context, p *Proxy, u *limitedUpstream) error {
res, err := p.GetOrFetchArtifactFromURL(ctx, "npm", "left-pad", "1.3.0", "left-pad-1.3.0.tgz", u.artifactURL())
if err == nil {
_ = res.Reader.Close()
}
return err
}
// requireHeld checks that err is answered like any held upstream request,
// with 503 and a Retry-After.
func requireHeld(t *testing.T, name string, err error) {
t.Helper()
w := httptest.NewRecorder()
if !writeUpstreamHeld(w, err) {
t.Fatalf("%s: err = %v, want a held upstream", name, err)
}
if w.Code != http.StatusServiceUnavailable || w.Header().Get("Retry-After") == "" {
t.Fatalf("%s: status %d, Retry-After %q; want 503 with Retry-After", name, w.Code, w.Header().Get("Retry-After"))
}
}
// TestSharedHoldStopsOtherInstance checks that once one instance is rate
// limited, another sharing its database refuses requests to that host
// without sending them, until the hold ends.
func TestSharedHoldStopsOtherInstance(t *testing.T) {
path := filepath.Join(t.TempDir(), "shared.db")
dbA, err := database.Create(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = dbA.Close() })
dbB, err := database.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = dbB.Close() })
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
store := newMockStorage()
a := NewProxy(dbA, store, &mockFetcher{}, fetch.NewResolver(), logger)
b := NewProxy(dbB, store, &mockFetcher{}, fetch.NewResolver(), logger)
u := newLimitedUpstream(t, false)
useSharedGate(a, dbA, u)
gateB := useSharedGate(b, dbB, u)
gateB.Refresh = 0
if err := fetchLimited(context.Background(), a, u); err == nil {
t.Fatal("instance a: want the rate limit error")
}
if got := u.calls.Load(); got != 1 {
t.Fatalf("upstream requests after a = %d, want 1", got)
}
requireHeld(t, "instance b artifact", fetchLimited(context.Background(), b, u))
_, _, err = b.FetchOrCacheMetadata(context.Background(), "npm", "left-pad", u.server.URL+"/left-pad", "application/json")
if !errors.Is(err, registryclient.ErrUpstreamHeld) {
t.Errorf("instance b metadata: err = %v, want a held upstream", err)
}
if got := u.calls.Load(); got != 1 {
t.Errorf("upstream requests after b = %d, want 1", got)
}
// The hold ends when the upstream said, and is then deleted.
holds, err := dbB.UpstreamHolds(time.Now().Add(11 * time.Minute))
if err != nil || len(holds) != 0 {
t.Fatalf("holds after expiry = %v, %v; want none", holds, err)
}
if err := b.upstreamHeld(u.artifactURL()); err != nil {
t.Errorf("instance b after expiry: %v, want no hold", err)
}
}
// TestFetchLeaseRateLimitedHolderStopsWaiters checks that instances waiting
// on a fetch that was rate limited answer with the holder's Retry-After
// instead of each sending one more request to the limited host.
func TestFetchLeaseRateLimitedHolderStopsWaiters(t *testing.T) {
a, b, db := sharedInstances(t)
u := newLimitedUpstream(t, true)
useSharedGate(a, db, u)
gateB := useSharedGate(b, db, u)
// b has read the shared holds just before, and would not read them
// again for an hour on its own.
gateB.Refresh = time.Hour
if err := b.upstreamHeld(u.artifactURL()); err != nil {
t.Fatal(err)
}
waits := testutil.ToFloat64(metrics.FetchLeaseWaits.WithLabelValues(metrics.CacheKindArtifact))
resA := make(chan error, 1)
go func() { resA <- fetchLimited(context.Background(), a, u) }()
<-u.entered
resB := make(chan error, 1)
go func() { resB <- fetchLimited(context.Background(), b, u) }()
waitForLeaseWaits(t, metrics.CacheKindArtifact, waits)
u.open()
if err := <-resA; err == nil {
t.Fatal("instance a: want the rate limit error")
}
requireHeld(t, "instance b", <-resB)
if got := u.calls.Load(); got != 1 {
t.Errorf("upstream requests = %d, want 1", got)
}
}