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
72 changes: 72 additions & 0 deletions pkg/server/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Package server provides HTTP health check endpoints for PicoClaw
package server

import (
"encoding/json"
"net/http"
"runtime"
"time"
)

var (
// StartTime records when the server started
StartTime time.Time
// Version holds the application version
Version = "0.1.0"
// AgentName holds the agent name
AgentName = "PicoClaw"
)

func init() {
StartTime = time.Now()
}

// HealthResponse represents the JSON response from /healthz
type HealthResponse struct {
Status string `json:"status"`
Uptime string `json:"uptime"`
Version string `json:"version"`
AgentName string `json:"agent_name"`
GoVersion string `json:"go_version"`
BuildTime string `json:"build_time,omitempty"`
}

// HealthzHandler handles GET /healthz requests
func HealthzHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

uptime := time.Since(StartTime)
resp := HealthResponse{
Status: "ok",
Uptime: uptime.String(),
Version: Version,
AgentName: AgentName,
GoVersion: runtime.Version(),
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error handling after WriteHeader is ineffective

Medium Severity

w.WriteHeader(http.StatusOK) is called before json.NewEncoder(w).Encode(resp). If encoding fails, the subsequent http.Error() call attempts to set a 500 status code, but WriteHeader can only be called once per response in Go — the status is already committed as 200. The error handling silently does nothing useful and produces a "superfluous response.WriteHeader call" log warning. Encoding into a buffer first and only writing the header after success would make the error path functional.

Fix in Cursor Fix in Web

}

// NewHealthServer creates an HTTP server with the /healthz endpoint
func NewHealthServer(addr string) *http.Server {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", HealthzHandler)

return &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
}
}
169 changes: 169 additions & 0 deletions pkg/server/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package server

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)

func TestHealthzHandler(t *testing.T) {
tests := []struct {
name string
method string
expectedStatus int
expectedOK bool
}{
{
name: "GET request returns 200",
method: http.MethodGet,
expectedStatus: http.StatusOK,
expectedOK: true,
},
{
name: "POST request returns 405",
method: http.MethodPost,
expectedStatus: http.StatusMethodNotAllowed,
expectedOK: false,
},
{
name: "PUT request returns 405",
method: http.MethodPut,
expectedStatus: http.StatusMethodNotAllowed,
expectedOK: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/healthz", nil)
w := httptest.NewRecorder()

HealthzHandler(w, req)

resp := w.Result()
defer resp.Body.Close()

if resp.StatusCode != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, resp.StatusCode)
}

if tt.expectedOK {
var health HealthResponse
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
t.Fatalf("failed to decode response: %v", err)
}

if health.Status != "ok" {
t.Errorf("expected status 'ok', got '%s'", health.Status)
}

if health.Version == "" {
t.Error("expected non-empty version")
}

if health.AgentName == "" {
t.Error("expected non-empty agent_name")
}

if health.GoVersion == "" {
t.Error("expected non-empty go_version")
}

if health.Uptime == "" {
t.Error("expected non-empty uptime")
}
}
})
}
}

func TestHealthzResponseFormat(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()

HealthzHandler(w, req)

resp := w.Result()
defer resp.Body.Close()

contentType := resp.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("expected Content-Type 'application/json', got '%s'", contentType)
}

var health HealthResponse
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
t.Fatalf("failed to decode JSON: %v", err)
}

// Validate JSON structure
expectedFields := map[string]bool{
"status": health.Status != "",
"uptime": health.Uptime != "",
"version": health.Version != "",
"agent_name": health.AgentName != "",
"go_version": health.GoVersion != "",
}

for field, exists := range expectedFields {
if !exists {
t.Errorf("expected field '%s' to be non-empty", field)
}
}
}

func TestNewHealthServer(t *testing.T) {
addr := ":8080"
srv := NewHealthServer(addr)

if srv.Addr != addr {
t.Errorf("expected address %s, got %s", addr, srv.Addr)
}

if srv.ReadTimeout != 10*time.Second {
t.Errorf("expected ReadTimeout 10s, got %v", srv.ReadTimeout)
}

if srv.WriteTimeout != 10*time.Second {
t.Errorf("expected WriteTimeout 10s, got %v", srv.WriteTimeout)
}

if srv.IdleTimeout != 30*time.Second {
t.Errorf("expected IdleTimeout 30s, got %v", srv.IdleTimeout)
}

if srv.Handler == nil {
t.Error("expected non-nil Handler")
}
}

func TestHealthzUptime(t *testing.T) {
// Reset StartTime to a known value
oldStartTime := StartTime
StartTime = time.Now().Add(-5 * time.Minute)
defer func() { StartTime = oldStartTime }()

req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()

HealthzHandler(w, req)

var health HealthResponse
json.NewDecoder(w.Result().Body).Decode(&health)

// Parse the uptime duration
duration, err := time.ParseDuration(health.Uptime)
if err != nil {
t.Fatalf("failed to parse uptime: %v", err)
}

// Should be approximately 5 minutes (allow 1 second tolerance)
expected := 5 * time.Minute
tolerance := 1 * time.Second

if duration < expected-tolerance || duration > expected+tolerance {
t.Errorf("expected uptime ~%v, got %v", expected, duration)
}
}