-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add health check endpoint /healthz #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Openclaw-ai-dev
wants to merge
1
commit into
Clawland-AI:main
from
Openclaw-ai-dev:feat/healthz-endpoint
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } | ||
|
|
||
| // 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, | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 beforejson.NewEncoder(w).Encode(resp). If encoding fails, the subsequenthttp.Error()call attempts to set a 500 status code, butWriteHeadercan 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.