Files
box-box/internal/api/pacing_test.go

131 lines
3.5 KiB
Go
Raw Normal View History

package api
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
)
// newPacedTestClient builds a client against a test server with an isolated cache
// (HOME is pointed at a temp dir so the SQLite cache never touches the real one).
func newPacedTestClient(t *testing.T, srvURL string, interval time.Duration) *OpenF1Client {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
c := NewOpenF1Client(srvURL, 5*time.Second)
c.pacer = &requestPacer{interval: interval}
t.Cleanup(func() { _ = c.Close() })
return c
}
func TestRequestPacerSpacesConcurrentCallers(t *testing.T) {
p := &requestPacer{interval: 20 * time.Millisecond}
const callers = 5
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < callers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
p.wait()
}()
}
wg.Wait()
// 5 callers at 20ms spacing: the last slot is 80ms after the first.
if elapsed := time.Since(start); elapsed < 4*20*time.Millisecond {
t.Fatalf("pacer did not space callers: %d finished in %v", callers, elapsed)
}
}
func TestRequestPacerNilSafe(t *testing.T) {
var p *requestPacer
p.wait() // must not panic
}
func TestRequestPacerCancellationReturnsUnusedReservation(t *testing.T) {
p := &requestPacer{interval: 100 * time.Millisecond}
if err := p.waitContext(context.Background()); err != nil {
t.Fatal(err)
}
p.mu.Lock()
wantNext := p.next
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
if err := p.waitContext(ctx); err == nil {
t.Fatal("expected paced wait cancellation")
}
p.mu.Lock()
gotNext := p.next
p.mu.Unlock()
if !gotNext.Equal(wantNext) {
t.Fatalf("cancelled reservation left pacing debt: next %v, want %v", gotNext, wantNext)
}
}
func TestGetRetriesOn429(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) <= 2 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
return
}
_ = json.NewEncoder(w).Encode([]map[string]any{{"meeting_key": 1}})
}))
defer srv.Close()
c := newPacedTestClient(t, srv.URL, time.Millisecond)
body, err := c.get(srv.URL + "/v1/meetings")
if err != nil {
t.Fatalf("get after 429s should succeed, got %v", err)
}
data, _ := io.ReadAll(body)
body.Close()
if len(data) == 0 {
t.Fatal("expected response body after retries")
}
if got := calls.Load(); got != 3 {
t.Fatalf("expected 3 attempts (2×429 + 1×200), got %d", got)
}
}
func TestGetGivesUpAfterMaxRetries(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := newPacedTestClient(t, srv.URL, time.Millisecond)
_, err := c.get(srv.URL + "/v1/meetings")
if err == nil {
t.Fatal("expected error when server keeps returning 429")
}
if got := calls.Load(); got != int32(max429Retries)+1 {
t.Fatalf("expected %d attempts, got %d", max429Retries+1, got)
}
}
func TestRetryAfter429Cap(t *testing.T) {
resp := &http.Response{Header: http.Header{"Retry-After": []string{"3600"}}}
if got := retryAfter429(resp); got != 10*time.Second {
t.Fatalf("expected 10s cap, got %v", got)
}
resp = &http.Response{Header: http.Header{}}
if got := retryAfter429(resp); got != time.Second {
t.Fatalf("expected 1s default, got %v", got)
}
}