diff --git a/internal/client/client.go b/internal/client/client.go index 63b638c5..084f988c 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -65,11 +65,15 @@ func NewClientWithConfig(baseURL, token string) (*Client, error) { func pingWithRetry(c *Client) error { var lastErr error - const attempts = 3 + const attempts = 5 + delay := time.Second for i := range attempts { if err := c.Ping(); err != nil { lastErr = err - time.Sleep(time.Duration(i+1) * time.Second) + if i < attempts-1 { + time.Sleep(delay) + delay *= 2 + } continue } return nil diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 00000000..707a0e66 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,55 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestPingWithRetry_ImmediateSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient(srv.URL, "") + if err := pingWithRetry(c); err != nil { + t.Fatalf("pingWithRetry failed on immediate success: %v", err) + } +} + +func TestPingWithRetry_SucceedsAfterFailures(t *testing.T) { + var calls atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + if n < 3 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient(srv.URL, "") + if err := pingWithRetry(c); err != nil { + t.Fatalf("pingWithRetry failed: %v (calls=%d)", err, calls.Load()) + } + if calls.Load() < 3 { + t.Fatalf("expected at least 3 calls, got %d", calls.Load()) + } +} + +func TestPingWithRetry_AllFail(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + c := NewClient(srv.URL, "") + err := pingWithRetry(c) + if err == nil { + t.Fatal("expected error when all pings fail") + } +} diff --git a/pkg/cli/root.go b/pkg/cli/root.go index df7520ec..4c858e0f 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -64,6 +64,9 @@ var rootCmd = &cobra.Command{ if err := dm.Start(); err != nil { return fmt.Errorf("failed to start daemon: %w", err) } + if err := dm.WaitForReady(); err != nil { + return fmt.Errorf("daemon started but not ready: %w", err) + } } } diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index 8a0ef7e1..d5e19256 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -151,6 +151,25 @@ func (d *DefaultDaemonManager) Start() error { return nil } +func (d *DefaultDaemonManager) WaitForReady() error { + httpClient := &http.Client{Timeout: 2 * time.Second} + deadline := time.Now().Add(30 * time.Second) + delay := 500 * time.Millisecond + + for time.Now().Before(deadline) { + resp, err := httpClient.Get("http://localhost:12121/v0/ping") + if err == nil { + resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + } + time.Sleep(delay) + delay = min(delay*2, 4*time.Second) + } + return fmt.Errorf("daemon did not become ready within 30 seconds") +} + func (d *DefaultDaemonManager) IsRunning() bool { // First check if a server is responding on the API port (local or Docker) if isServerResponding() { diff --git a/pkg/daemon/daemon_test.go b/pkg/daemon/daemon_test.go new file mode 100644 index 00000000..480ed238 --- /dev/null +++ b/pkg/daemon/daemon_test.go @@ -0,0 +1,93 @@ +package daemon + +import ( + "net" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/agentregistry-dev/agentregistry/pkg/types" +) + +func TestDefaultDaemonManagerImplementsInterface(t *testing.T) { + var _ types.DaemonManager = (*DefaultDaemonManager)(nil) +} + +func TestWaitForReady_AlreadyReady(t *testing.T) { + // Start a test server on port 12121 that immediately responds + listener, err := net.Listen("tcp", "127.0.0.1:12121") + if err != nil { + t.Skip("port 12121 already in use, skipping") + } + + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })} + go srv.Serve(listener) + defer srv.Close() + + dm := NewDaemonManager(nil) + if err := dm.WaitForReady(); err != nil { + t.Fatalf("WaitForReady failed when server was already ready: %v", err) + } +} + +func TestWaitForReady_BecomesReadyAfterDelay(t *testing.T) { + var ready atomic.Bool + + // Start a test server that returns 503 until we set ready=true + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ready.Load() { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusServiceUnavailable) + } + }) + + listener, err := net.Listen("tcp", "127.0.0.1:12121") + if err != nil { + t.Skip("port 12121 already in use, skipping") + } + + srv := &http.Server{Handler: handler} + go srv.Serve(listener) + defer srv.Close() + + // Make server ready after 2 seconds + go func() { + time.Sleep(2 * time.Second) + ready.Store(true) + }() + + dm := NewDaemonManager(nil) + start := time.Now() + if err := dm.WaitForReady(); err != nil { + t.Fatalf("WaitForReady failed: %v", err) + } + elapsed := time.Since(start) + if elapsed < 2*time.Second { + t.Fatalf("WaitForReady returned too quickly: %v", elapsed) + } +} + +func TestIsServerResponding(t *testing.T) { + // When no server is running on 12121, isServerResponding should return false quickly + // (This test may be flaky if something is actually running on 12121) + listener, err := net.Listen("tcp", "127.0.0.1:12121") + if err != nil { + t.Skip("port 12121 already in use, skipping") + } + + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + srv.Listener = listener + srv.Start() + defer srv.Close() + + if !isServerResponding() { + t.Fatal("isServerResponding returned false when server is running") + } +} diff --git a/pkg/types/types.go b/pkg/types/types.go index cf732b3a..dd89f6b0 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -84,6 +84,8 @@ type DaemonManager interface { IsRunning() bool // Start starts the daemon, blocking until it's ready Start() error + // WaitForReady polls the daemon API until it responds or the timeout expires + WaitForReady() error } // CLIAuthnProvider provides authentication for CLI commands.