package ingest import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" ) func TestFetchExportLimitedRetries429ThenSucceeds(t *testing.T) { var attempts atomic.Int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := attempts.Add(1) if n <= 2 { w.Header().Set("Retry-After", "0") http.Error(w, "rate limited", http.StatusTooManyRequests) return } _, _ = fmt.Fprintln(w, `{"seq":1,"did":"did:plc:alice","cid":"cid1","operation":{"x":1}}`) })) defer ts.Close() client := NewClient(ts.URL, ClientOptions{ MaxAttempts: 5, BaseDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond, }) records, err := client.FetchExportLimited(context.Background(), 0, 0) if err != nil { t.Fatalf("fetch export: %v", err) } if len(records) != 1 { t.Fatalf("record count mismatch: got %d want 1", len(records)) } if got := attempts.Load(); got != 3 { t.Fatalf("attempt count mismatch: got %d want 3", got) } } func TestFetchExportLimitedDoesNotRetry400(t *testing.T) { var attempts atomic.Int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) http.Error(w, "bad request", http.StatusBadRequest) })) defer ts.Close() client := NewClient(ts.URL, ClientOptions{ MaxAttempts: 5, BaseDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond, }) _, err := client.FetchExportLimited(context.Background(), 0, 0) if err == nil { t.Fatalf("expected error for 400 response") } if got := attempts.Load(); got != 1 { t.Fatalf("unexpected retries on 400: got attempts=%d want 1", got) } } func TestFetchDIDLogRetries429ThenSucceeds(t *testing.T) { const did = "did:plc:retry429" var attempts atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/"+did+"/log" { http.NotFound(w, r) return } n := attempts.Add(1) if n <= 2 { w.Header().Set("Retry-After", "0") http.Error(w, "rate limited", http.StatusTooManyRequests) return } _ = json.NewEncoder(w).Encode([]map[string]any{ { "did": did, "cid": "bafy-retry", "createdAt": "2026-02-27T00:00:00Z", "operation": json.RawMessage(`{"did":"did:plc:retry429","sig":"x","sigPayload":"e30","rotationKeys":["did:key:z"],"verificationMethods":{"atproto":"did:key:z"}}`), }, }) })) defer srv.Close() client := NewClient(srv.URL, ClientOptions{ MaxAttempts: 5, BaseDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond, }) records, err := client.FetchDIDLog(context.Background(), did) if err != nil { t.Fatalf("fetch did log: %v", err) } if len(records) != 1 { t.Fatalf("record count mismatch: got %d want 1", len(records)) } if got := attempts.Load(); got != 3 { t.Fatalf("attempt count mismatch: got %d want 3", got) } } func TestFetchDIDLogAuditFallbackRetries429ThenSucceeds(t *testing.T) { const did = "did:plc:auditretry" var auditAttempts atomic.Int32 op := json.RawMessage(`{"did":"did:plc:auditretry","sig":"x","sigPayload":"e30","rotationKeys":["did:key:z"],"verificationMethods":{"atproto":"did:key:z"}}`) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/" + did + "/log": _ = json.NewEncoder(w).Encode([]json.RawMessage{op}) case "/" + did + "/log/audit": n := auditAttempts.Add(1) if n == 1 { w.Header().Set("Retry-After", "0") http.Error(w, "rate limited", http.StatusTooManyRequests) return } _ = json.NewEncoder(w).Encode([]map[string]any{{ "did": did, "operation": op, "cid": "bafy-audit", "createdAt": "2026-02-27T00:00:00Z", }}) default: http.NotFound(w, r) } })) defer srv.Close() client := NewClient(srv.URL, ClientOptions{ MaxAttempts: 5, BaseDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond, }) records, err := client.FetchDIDLog(context.Background(), did) if err != nil { t.Fatalf("fetch did log: %v", err) } if len(records) != 1 { t.Fatalf("record count mismatch: got %d want 1", len(records)) } if records[0].CID != "bafy-audit" { t.Fatalf("cid mismatch: got %q want %q", records[0].CID, "bafy-audit") } if got := auditAttempts.Load(); got != 2 { t.Fatalf("audit attempt count mismatch: got %d want 2", got) } }