Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
3 changes: 3 additions & 0 deletions pkg/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down
19 changes: 19 additions & 0 deletions pkg/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
93 changes: 93 additions & 0 deletions pkg/daemon/daemon_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
2 changes: 2 additions & 0 deletions pkg/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading