Skip to content

Commit 81aec47

Browse files
authored
Merge pull request #186 from hookdeck/feat/listen-healthcheck
feat: add TCP-based local server health checks to listen command
2 parents 876e9fe + 2eefc89 commit 81aec47

12 files changed

Lines changed: 522 additions & 25 deletions

File tree

pkg/cmd/listen.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ Destination CLI path will be "/". To set the CLI path, use the "--path" flag.`,
153153
lc.cmd.Flags().StringVar(&lc.path, "path", "", "Sets the path to which events are forwarded e.g., /webhooks or /api/stripe")
154154
lc.cmd.Flags().IntVar(&lc.maxConnections, "max-connections", 50, "Maximum concurrent connections to local endpoint (default: 50, increase for high-volume testing)")
155155

156-
lc.cmd.Flags().StringVar(&lc.output, "output", "interactive", "Output mode: interactive (full UI), compact (simple logs), quiet (only fatal errors)")
156+
lc.cmd.Flags().StringVar(&lc.output, "output", "interactive", "Output mode: interactive (full UI), compact (simple logs), quiet (errors and warnings only)")
157157

158158
lc.cmd.Flags().StringVar(&lc.filterBody, "filter-body", "", "Filter events by request body using Hookdeck filter syntax (JSON)")
159159
lc.cmd.Flags().StringVar(&lc.filterHeaders, "filter-headers", "", "Filter events by request headers using Hookdeck filter syntax (JSON)")

pkg/listen/healthcheck.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package listen
2+
3+
import (
4+
"net/url"
5+
"time"
6+
7+
"github.com/hookdeck/hookdeck-cli/pkg/listen/healthcheck"
8+
)
9+
10+
// Re-export types and constants from healthcheck subpackage for convenience
11+
type ServerHealthStatus = healthcheck.ServerHealthStatus
12+
type HealthCheckResult = healthcheck.HealthCheckResult
13+
14+
const (
15+
HealthHealthy = healthcheck.HealthHealthy
16+
HealthUnreachable = healthcheck.HealthUnreachable
17+
)
18+
19+
// CheckServerHealth performs a TCP connection check to the target URL
20+
// This is a wrapper around the healthcheck package function for backward compatibility
21+
func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult {
22+
return healthcheck.CheckServerHealth(targetURL, timeout)
23+
}
24+
25+
// FormatHealthMessage creates a user-friendly health status message
26+
// This is a wrapper around the healthcheck package function for backward compatibility
27+
func FormatHealthMessage(result HealthCheckResult, targetURL *url.URL) string {
28+
return healthcheck.FormatHealthMessage(result, targetURL)
29+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package healthcheck
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"net/url"
7+
"os"
8+
"time"
9+
10+
"github.com/hookdeck/hookdeck-cli/pkg/ansi"
11+
)
12+
13+
// ServerHealthStatus represents the health status of the target server
14+
type ServerHealthStatus int
15+
16+
const (
17+
HealthHealthy ServerHealthStatus = iota // TCP connection successful
18+
HealthUnreachable // Connection refused or timeout
19+
)
20+
21+
// HealthCheckResult contains the result of a health check
22+
type HealthCheckResult struct {
23+
Status ServerHealthStatus
24+
Healthy bool
25+
Error error
26+
Timestamp time.Time
27+
Duration time.Duration
28+
}
29+
30+
// CheckServerHealth performs a TCP connection check to verify a server is listening.
31+
// The timeout parameter should be appropriate for the deployment context:
32+
// - Local development: 3s is typically sufficient
33+
// - Production/edge: May require longer timeouts due to network conditions
34+
func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult {
35+
start := time.Now()
36+
37+
host := targetURL.Hostname()
38+
port := targetURL.Port()
39+
40+
// Default ports if not specified
41+
if port == "" {
42+
if targetURL.Scheme == "https" {
43+
port = "443"
44+
} else {
45+
port = "80"
46+
}
47+
}
48+
49+
address := net.JoinHostPort(host, port)
50+
51+
conn, err := net.DialTimeout("tcp", address, timeout)
52+
duration := time.Since(start)
53+
54+
result := HealthCheckResult{
55+
Timestamp: start,
56+
Duration: duration,
57+
}
58+
59+
if err != nil {
60+
result.Healthy = false
61+
result.Error = err
62+
result.Status = HealthUnreachable
63+
return result
64+
}
65+
66+
// Successfully connected - server is healthy
67+
conn.Close()
68+
result.Healthy = true
69+
result.Status = HealthHealthy
70+
return result
71+
}
72+
73+
// FormatHealthMessage creates a user-friendly health status message
74+
func FormatHealthMessage(result HealthCheckResult, targetURL *url.URL) string {
75+
if result.Healthy {
76+
return fmt.Sprintf("→ Local server is reachable at %s", targetURL.String())
77+
}
78+
79+
color := ansi.Color(os.Stdout)
80+
errorMessage := "unknown error"
81+
if result.Error != nil {
82+
errorMessage = result.Error.Error()
83+
}
84+
return fmt.Sprintf("%s Cannot connect to local server at %s\n %s\n The server may not be running. Events will fail until the server starts.",
85+
color.Yellow("● Warning:"),
86+
targetURL.String(),
87+
errorMessage)
88+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package healthcheck
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"strings"
10+
"testing"
11+
"time"
12+
)
13+
14+
func TestCheckServerHealth_HealthyServer(t *testing.T) {
15+
// Start a test HTTP server
16+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17+
w.WriteHeader(http.StatusOK)
18+
}))
19+
defer server.Close()
20+
21+
// Parse server URL
22+
serverURL, err := url.Parse(server.URL)
23+
if err != nil {
24+
t.Fatalf("Failed to parse server URL: %v", err)
25+
}
26+
27+
// Perform health check
28+
result := CheckServerHealth(serverURL, 3*time.Second)
29+
30+
// Verify result
31+
if !result.Healthy {
32+
t.Errorf("Expected server to be healthy, got unhealthy")
33+
}
34+
if result.Status != HealthHealthy {
35+
t.Errorf("Expected status HealthHealthy, got %v", result.Status)
36+
}
37+
if result.Error != nil {
38+
t.Errorf("Expected no error, got: %v", result.Error)
39+
}
40+
if result.Duration <= 0 {
41+
t.Errorf("Expected positive duration, got: %v", result.Duration)
42+
}
43+
}
44+
45+
func TestCheckServerHealth_UnreachableServer(t *testing.T) {
46+
// Use a URL that should not be listening
47+
targetURL, err := url.Parse("http://localhost:59999")
48+
if err != nil {
49+
t.Fatalf("Failed to parse URL: %v", err)
50+
}
51+
52+
// Perform health check
53+
result := CheckServerHealth(targetURL, 1*time.Second)
54+
55+
// Verify result
56+
if result.Healthy {
57+
t.Errorf("Expected server to be unhealthy, got healthy")
58+
}
59+
if result.Status != HealthUnreachable {
60+
t.Errorf("Expected status HealthUnreachable, got %v", result.Status)
61+
}
62+
if result.Error == nil {
63+
t.Errorf("Expected error, got nil")
64+
}
65+
}
66+
67+
func TestCheckServerHealth_DefaultPorts(t *testing.T) {
68+
testCases := []struct {
69+
name string
70+
urlString string
71+
expectedPort string
72+
}{
73+
{
74+
name: "HTTP default port",
75+
urlString: "http://localhost",
76+
expectedPort: "80",
77+
},
78+
{
79+
name: "HTTPS default port",
80+
urlString: "https://localhost",
81+
expectedPort: "443",
82+
},
83+
{
84+
name: "Explicit port",
85+
urlString: "http://localhost:8080",
86+
expectedPort: "8080",
87+
},
88+
}
89+
90+
for _, tc := range testCases {
91+
t.Run(tc.name, func(t *testing.T) {
92+
targetURL, err := url.Parse(tc.urlString)
93+
if err != nil {
94+
t.Fatalf("Failed to parse URL: %v", err)
95+
}
96+
97+
// Start a listener on the expected port to verify we're checking the right one
98+
listener, err := net.Listen("tcp", "localhost:"+tc.expectedPort)
99+
if err != nil {
100+
t.Skipf("Cannot bind to port %s: %v", tc.expectedPort, err)
101+
}
102+
defer listener.Close()
103+
104+
// Perform health check
105+
result := CheckServerHealth(targetURL, 1*time.Second)
106+
107+
// Should be healthy since we have a listener
108+
if !result.Healthy {
109+
t.Errorf("Expected server to be healthy on port %s, got unhealthy: %v", tc.expectedPort, result.Error)
110+
}
111+
})
112+
}
113+
}
114+
115+
func TestFormatHealthMessage_Healthy(t *testing.T) {
116+
targetURL, _ := url.Parse("http://localhost:3000")
117+
result := HealthCheckResult{
118+
Status: HealthHealthy,
119+
Healthy: true,
120+
}
121+
122+
msg := FormatHealthMessage(result, targetURL)
123+
124+
if len(msg) == 0 {
125+
t.Errorf("Expected non-empty message")
126+
}
127+
if !strings.Contains(msg, "→") {
128+
t.Errorf("Expected message to contain →")
129+
}
130+
if !strings.Contains(msg, "Local server is reachable") {
131+
t.Errorf("Expected message to contain 'Local server is reachable'")
132+
}
133+
}
134+
135+
func TestFormatHealthMessage_Unhealthy(t *testing.T) {
136+
targetURL, _ := url.Parse("http://localhost:3000")
137+
result := HealthCheckResult{
138+
Status: HealthUnreachable,
139+
Healthy: false,
140+
Error: net.ErrClosed,
141+
}
142+
143+
msg := FormatHealthMessage(result, targetURL)
144+
145+
if len(msg) == 0 {
146+
t.Errorf("Expected non-empty message")
147+
}
148+
// Should contain warning indicator
149+
if !strings.Contains(msg, "●") {
150+
t.Errorf("Expected message to contain ●")
151+
}
152+
if !strings.Contains(msg, "Warning") {
153+
t.Errorf("Expected message to contain 'Warning'")
154+
}
155+
}
156+
157+
func TestFormatHealthMessage_NilError(t *testing.T) {
158+
targetURL, _ := url.Parse("http://localhost:3000")
159+
result := HealthCheckResult{
160+
Status: HealthUnreachable,
161+
Healthy: false,
162+
Error: nil, // Nil error should not cause panic
163+
}
164+
165+
msg := FormatHealthMessage(result, targetURL)
166+
167+
if len(msg) == 0 {
168+
t.Errorf("Expected non-empty message")
169+
}
170+
if !strings.Contains(msg, "unknown error") {
171+
t.Errorf("Expected message to contain 'unknown error' when error is nil")
172+
}
173+
}
174+
175+
func TestCheckServerHealth_PortInURL(t *testing.T) {
176+
// Create a server on a non-standard port
177+
listener, err := net.Listen("tcp", "localhost:0")
178+
if err != nil {
179+
t.Fatalf("Failed to create listener: %v", err)
180+
}
181+
defer listener.Close()
182+
183+
// Get the actual port assigned by the OS
184+
addr := listener.Addr().(*net.TCPAddr)
185+
targetURL, _ := url.Parse(fmt.Sprintf("http://localhost:%d/path", addr.Port))
186+
187+
// Perform health check
188+
result := CheckServerHealth(targetURL, 3*time.Second)
189+
190+
// Verify that the health check succeeded
191+
// This confirms that when a port is already in the URL, we don't append
192+
// a default port (which would cause localhost:8080 to become localhost:8080:80)
193+
if !result.Healthy {
194+
t.Errorf("Expected healthy=true for server with port in URL, got false: %v", result.Error)
195+
}
196+
if result.Error != nil {
197+
t.Errorf("Expected no error for server with port in URL, got: %v", result.Error)
198+
}
199+
}

pkg/listen/listen.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"os"
2424
"regexp"
2525
"strings"
26+
"time"
2627

2728
"github.com/hookdeck/hookdeck-cli/pkg/config"
2829
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
@@ -122,6 +123,31 @@ Specify a single destination to update the path. For example, pass a connection
122123
return err
123124
}
124125

126+
// Perform initial health check on target server
127+
// Using 3-second timeout optimized for local development scenarios.
128+
// This assumes low latency to localhost. For production/edge deployments,
129+
// this timeout may need to be configurable in future iterations.
130+
healthCheckTimeout := 3 * time.Second
131+
healthResult := CheckServerHealth(URL, healthCheckTimeout)
132+
133+
// For all output modes, warn if server isn't reachable
134+
if !healthResult.Healthy {
135+
warningMsg := FormatHealthMessage(healthResult, URL)
136+
137+
if flags.Output == "interactive" {
138+
// Interactive mode will show warning before TUI starts
139+
fmt.Println()
140+
fmt.Println(warningMsg)
141+
fmt.Println()
142+
time.Sleep(500 * time.Millisecond) // Give user time to see warning before TUI starts
143+
} else {
144+
// Compact/quiet modes: print warning before connection info
145+
fmt.Println()
146+
fmt.Println(warningMsg)
147+
fmt.Println()
148+
}
149+
}
150+
125151
// Start proxy
126152
// For non-interactive modes, print connection info before starting
127153
if flags.Output == "compact" || flags.Output == "quiet" {

0 commit comments

Comments
 (0)