From a5f907854f29e1c267ad30d1dfe85c2c47f5ac48 Mon Sep 17 00:00:00 2001 From: sinner Date: Wed, 15 Apr 2026 15:16:02 -0400 Subject: feat: add stdin support and retry logic for all search commands --- internal/client/retry_test.go | 247 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 internal/client/retry_test.go (limited to 'internal/client/retry_test.go') diff --git a/internal/client/retry_test.go b/internal/client/retry_test.go new file mode 100644 index 0000000..532ddb7 --- /dev/null +++ b/internal/client/retry_test.go @@ -0,0 +1,247 @@ +package client + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "git.db.org.ai/dborg/internal/config" +) + +func testConfig(baseURL string) *config.Config { + return &config.Config{ + APIKey: "test-key", + BaseURL: baseURL, + Timeout: 5 * time.Second, + MaxRetries: 3, + UserAgent: "dborg-test", + } +} + +func TestRedactKey(t *testing.T) { + tests := []struct { + in, want string + }{ + {"", "***"}, + {"short", "***"}, + {"12345678", "***"}, + {"abcd1234efgh5678", "abcd...5678"}, + } + for _, tt := range tests { + if got := redactKey(tt.in); got != tt.want { + t.Errorf("redactKey(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestIsRetryable(t *testing.T) { + retryable := []int{429, 408, 500, 502, 503, 504} + notRetryable := []int{200, 201, 301, 400, 401, 403, 404} + + for _, code := range retryable { + if !isRetryable(code) { + t.Errorf("isRetryable(%d) = false, want true", code) + } + } + for _, code := range notRetryable { + if isRetryable(code) { + t.Errorf("isRetryable(%d) = true, want false", code) + } + } +} + +func TestBackoffDelay_RetryAfterSeconds(t *testing.T) { + d := backoffDelay(1, "5") + if d != 5*time.Second { + t.Errorf("backoffDelay with Retry-After=5 = %v, want 5s", d) + } +} + +func TestBackoffDelay_RetryAfterHTTPDate(t *testing.T) { + future := time.Now().Add(10 * time.Second).UTC().Format(http.TimeFormat) + d := backoffDelay(1, future) + if d < 5*time.Second || d > 11*time.Second { + t.Errorf("backoffDelay with HTTP date = %v, want ~10s", d) + } +} + +func TestBackoffDelay_Exponential(t *testing.T) { + d1 := backoffDelay(1, "") + d2 := backoffDelay(2, "") + d3 := backoffDelay(3, "") + if d1 < 2*time.Second || d1 > 3*time.Second { + t.Errorf("attempt 1 delay = %v, want 2-3s", d1) + } + if d2 < 4*time.Second || d2 > 6*time.Second { + t.Errorf("attempt 2 delay = %v, want 4-6s", d2) + } + if d3 < 8*time.Second || d3 > 12*time.Second { + t.Errorf("attempt 3 delay = %v, want 8-12s", d3) + } +} + +func TestDoRequest_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-API-Key"); got != "test-key" { + t.Errorf("X-API-Key = %q, want test-key", got) + } + fmt.Fprintln(w, `{"ok":true}`) + })) + defer srv.Close() + + c, _ := New(testConfig(srv.URL)) + body, err := c.Get("/test", nil) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if !strings.Contains(string(body), `"ok":true`) { + t.Errorf("unexpected body: %s", body) + } +} + +func TestDoRequest_RetriesOn500ThenSucceeds(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n < 3 { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + fmt.Fprintln(w, `{"ok":true}`) + })) + defer srv.Close() + + cfg := testConfig(srv.URL) + cfg.MaxRetries = 5 + c, _ := New(cfg) + body, err := c.Get("/test", nil) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if atomic.LoadInt32(&calls) != 3 { + t.Errorf("calls = %d, want 3", calls) + } + if !strings.Contains(string(body), `"ok":true`) { + t.Errorf("unexpected body: %s", body) + } +} + +func TestDoRequest_NoRetryOn400(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + http.Error(w, "bad", http.StatusBadRequest) + })) + defer srv.Close() + + c, _ := New(testConfig(srv.URL)) + _, err := c.Get("/test", nil) + if err == nil { + t.Fatal("expected error") + } + if atomic.LoadInt32(&calls) != 1 { + t.Errorf("calls = %d, want 1 (no retries on 400)", calls) + } +} + +func TestDoRequest_NoRetryOn401(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + http.Error(w, "no auth", http.StatusUnauthorized) + })) + defer srv.Close() + + c, _ := New(testConfig(srv.URL)) + _, err := c.Get("/test", nil) + if err == nil { + t.Fatal("expected error") + } + if atomic.LoadInt32(&calls) != 1 { + t.Errorf("calls = %d, want 1", calls) + } +} + +func TestDoRequest_ExhaustsRetries(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + http.Error(w, "down", http.StatusBadGateway) + })) + defer srv.Close() + + cfg := testConfig(srv.URL) + cfg.MaxRetries = 2 + c, _ := New(cfg) + _, err := c.Get("/test", nil) + if err == nil { + t.Fatal("expected error after exhausting retries") + } + if atomic.LoadInt32(&calls) != 3 { + t.Errorf("calls = %d, want 3 (initial + 2 retries)", calls) + } +} + +func TestDoRequest_PostBodyResentOnRetry(t *testing.T) { + var calls int32 + var bodies []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + bodies = append(bodies, string(b)) + n := atomic.AddInt32(&calls, 1) + if n < 2 { + http.Error(w, "boom", http.StatusServiceUnavailable) + return + } + fmt.Fprintln(w, `{"ok":true}`) + })) + defer srv.Close() + + c, _ := New(testConfig(srv.URL)) + payload := map[string]string{"hello": "world"} + _, err := c.Post("/test", payload) + if err != nil { + t.Fatalf("Post() error = %v", err) + } + if len(bodies) != 2 { + t.Fatalf("got %d requests, want 2", len(bodies)) + } + if bodies[0] != bodies[1] { + t.Errorf("body mismatch between retries:\n first: %q\n second: %q", bodies[0], bodies[1]) + } + if !strings.Contains(bodies[0], `"hello":"world"`) { + t.Errorf("unexpected body: %q", bodies[0]) + } +} + +func TestDoRequest_RetryAfterHeaderHonored(t *testing.T) { + var calls int32 + var firstAt, secondAt time.Time + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n == 1 { + firstAt = time.Now() + w.Header().Set("Retry-After", "1") + http.Error(w, "slow down", http.StatusTooManyRequests) + return + } + secondAt = time.Now() + fmt.Fprintln(w, `{"ok":true}`) + })) + defer srv.Close() + + c, _ := New(testConfig(srv.URL)) + _, err := c.Get("/test", nil) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + gap := secondAt.Sub(firstAt) + if gap < 900*time.Millisecond { + t.Errorf("retry gap = %v, want >= 1s (Retry-After honored)", gap) + } +} -- cgit v1.2.3