Skip to content
Merged
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
93 changes: 91 additions & 2 deletions internal/checker/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ package checker
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"

"github.com/easymonitordev/probe-node/pkg/types"
Expand Down Expand Up @@ -67,7 +71,7 @@ func (h *HTTPChecker) Check(checkID int64, nodeID, url string, timeout time.Dura
result.ResponseTime = int(elapsed.Milliseconds())

if err != nil {
result.Error = err.Error()
result.Error = humanizeHTTPError(err, timeout)
return result
}
defer resp.Body.Close()
Expand All @@ -78,8 +82,93 @@ func (h *HTTPChecker) Check(checkID int64, nodeID, url string, timeout time.Dura
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
result.OK = true
} else {
result.Error = fmt.Sprintf("HTTP status: %d %s", resp.StatusCode, resp.Status)
result.Error = fmt.Sprintf("HTTP %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
}

return result
}

// humanizeHTTPError turns a raw Go HTTP error into a short, readable message
// suitable for display to end users. Falls back to the original error string
// when no specific classification matches.
func humanizeHTTPError(err error, timeout time.Duration) string {
// Context timeout (the per-check deadline elapsed).
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Sprintf("Request timed out after %s", formatDuration(timeout))
}

// DNS resolution failures.
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound {
return fmt.Sprintf("DNS lookup failed: host %q not found", dnsErr.Name)
}
return fmt.Sprintf("DNS lookup failed for %q: %s", dnsErr.Name, dnsErr.Err)
}

// TLS handshake / certificate issues. The stdlib doesn't expose a single
// typed error for these, so fall back to substring matching which is
// stable across versions.
raw := err.Error()
if strings.Contains(raw, "tls: ") || strings.Contains(raw, "x509:") {
if strings.Contains(raw, "certificate has expired") {
return "TLS certificate has expired"
}
if strings.Contains(raw, "certificate is valid for") {
return "TLS certificate does not match hostname"
}
if strings.Contains(raw, "unknown authority") || strings.Contains(raw, "signed by unknown authority") {
return "TLS certificate signed by unknown authority"
}
if strings.Contains(raw, "handshake failure") {
return "TLS handshake failed"
}
return "TLS error: " + trimAfter(raw, ": ")
}

// Connection-level failures (refused, reset, unreachable).
var netOpErr *net.OpError
if errors.As(err, &netOpErr) {
switch {
case strings.Contains(netOpErr.Error(), "connection refused"):
return "Connection refused"
case strings.Contains(netOpErr.Error(), "connection reset"):
return "Connection reset by peer"
case strings.Contains(netOpErr.Error(), "no route to host"):
return "No route to host"
case strings.Contains(netOpErr.Error(), "network is unreachable"):
return "Network unreachable"
}
}

// Redirect loops (hit the 10-redirect ceiling).
if strings.Contains(raw, "stopped after 10 redirects") {
return "Too many redirects (>10)"
}

// url.Error wraps most net errors; unwrap one layer so the URL isn't
// repeated in the message (the monitor already shows its URL).
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Err != nil {
return urlErr.Err.Error()
}

return raw
}

// formatDuration renders a timeout like "30s" or "1m30s".
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
return d.Truncate(time.Second).String()
}

// trimAfter returns s with everything up to and including the first occurrence
// of sep removed. Falls back to s if sep isn't found.
func trimAfter(s, sep string) string {
if i := strings.Index(s, sep); i >= 0 {
return strings.TrimSpace(s[i+len(sep):])
}
return s
}
21 changes: 20 additions & 1 deletion internal/checker/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,26 @@ func TestHTTPChecker_Check_Timeout(t *testing.T) {

assert.False(t, result.OK)
assert.NotEmpty(t, result.Error)
assert.Contains(t, result.Error, "deadline exceeded")
assert.Contains(t, result.Error, "timed out")
}

func TestHumanizeHTTPError_DNSNotFound(t *testing.T) {
checker := NewHTTPChecker()
result := checker.Check(10, "test-node", "https://this-host-does-not-exist-easymonitor.invalid", 5*time.Second)

assert.False(t, result.OK)
assert.Contains(t, result.Error, "DNS lookup failed")
}

func TestHumanizeHTTPError_ConnectionRefused(t *testing.T) {
// 127.0.0.1 on a port nothing is listening on.
checker := NewHTTPChecker()
result := checker.Check(11, "test-node", "http://127.0.0.1:1", 2*time.Second)

assert.False(t, result.OK)
// Could be "Connection refused" on Linux, other messages on other OSes — be lenient.
assert.NotContains(t, result.Error, "context deadline")
assert.NotEmpty(t, result.Error)
}

func TestHTTPChecker_Check_InvalidURL(t *testing.T) {
Expand Down
Loading