summaryrefslogtreecommitdiffstats
path: root/internal/client/retry_test.go
diff options
context:
space:
mode:
authorsinner <[email protected]>2026-04-15 15:16:02 -0400
committersinner <[email protected]>2026-04-15 15:16:02 -0400
commita5f907854f29e1c267ad30d1dfe85c2c47f5ac48 (patch)
treebc8685c3b22e6d5d47702ba0607c694f938ba7fd /internal/client/retry_test.go
parent8a1cf20dd5014ebe15ced77344902b79dcfa2e66 (diff)
downloaddborg-0.1.14.tar.gz
dborg-0.1.14.zip
feat: add stdin support and retry logic for all search commandsHEADv1.1.1v0.1.14master
Diffstat (limited to 'internal/client/retry_test.go')
-rw-r--r--internal/client/retry_test.go247
1 files changed, 247 insertions, 0 deletions
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)
+ }
+}