package httpclient import ( "context" "errors" "fmt" "net/http" "strings" "testing" "time" "evobgp/internal/observability" "github.com/prometheus/client_golang/prometheus" ) func TestBreakerCanonicalHostAndActiveRetention(t *testing.T) { c := breakerCache{entries: make(map[string]*breakerEntry), maximum: 1, idle: time.Minute} now := time.Now() first, key, release, err := c.acquire("Cache-Test.Example.", now) if err != nil || key != "cache-test.example" { t.Fatalf("canonical key=%s err=%v", key, err) } second, _, release2, err := c.acquire("cache-test.example", now) if err != nil || first != second || len(c.entries) != 1 { t.Fatal("alias bypassed shared breaker") } observability.SetUpstreamBreakerOpen(key, true) first.openUntil = now.Add(3 * time.Minute) if _, _, _, err := c.acquire("other.example", now.Add(2*time.Minute)); !errors.Is(err, ErrBreakerCapacity) { t.Fatalf("active entry evicted: %v", err) } release() release() release2() if _, _, _, err := c.acquire("other.example", now.Add(2*time.Minute+2*time.Second)); !errors.Is(err, ErrBreakerCapacity) { t.Fatalf("open breaker evicted: %v", err) } _, _, done, err := c.acquire("other.example", now.Add(4*time.Minute)) if err != nil { t.Fatal(err) } defer done() if len(c.entries) != 1 || c.entries[key] != nil { t.Fatal("idle closed entry retained") } families, err := prometheus.DefaultGatherer.Gather() if err != nil { t.Fatal(err) } for _, family := range families { if strings.HasSuffix(family.GetName(), "upstream_breaker_open") { for _, metric := range family.Metric { for _, label := range metric.Label { if label.GetName() == "host" && label.GetValue() == key { t.Fatal("evicted host metric retained") } } } } } } type breakerTransport func(*http.Request) (*http.Response, error) func (f breakerTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func TestBreakerCapacityRejectsBeforeDial(t *testing.T) { ResetHostBreakers() var releases []func() defer func() { for _, release := range releases { release() } ResetHostBreakers() }() for i := 0; i < hostBreakers.maximum; i++ { _, _, release, err := hostBreakers.acquire(fmt.Sprintf("capacity-%d.example", i), time.Now()) if err != nil { t.Fatal(err) } releases = append(releases, release) } called := false hc := &http.Client{Transport: breakerTransport(func(*http.Request) (*http.Response, error) { called = true; return nil, errors.New("unexpected dial") })} req, _ := http.NewRequest(http.MethodGet, "https://overflow.example/", nil) _, err := DoWithBreaker(context.Background(), hc, req, 1) if !errors.Is(err, ErrBreakerCapacity) || called { t.Fatalf("capacity err=%v dial=%t", err, called) } if len(SnapshotBreakers()) != hostBreakers.maximum { t.Fatal("cache capacity changed") } ResetHostBreakers() if len(SnapshotBreakers()) != hostBreakers.maximum { t.Fatal("reset discarded in-flight references") } } func TestBreakerRejectsOversizedIdentity(t *testing.T) { c := breakerCache{entries: make(map[string]*breakerEntry), maximum: 1, idle: time.Minute} if _, _, _, err := c.acquire(strings.Repeat("x", 254), time.Now()); err == nil || len(c.entries) != 0 { t.Fatal("oversized identity retained") } name := strings.Repeat("a", 253) first, key, release, err := c.acquire(name, time.Now()) if err != nil { t.Fatal(err) } defer release() second, alias, release2, err := c.acquire(strings.ToUpper(name)+".", time.Now()) if err != nil { t.Fatal(err) } defer release2() if first != second || key != alias { t.Fatal("maximum length trailing-dot alias differs") } }