Pulse/internal/tempproxy/http_client.go
rcourtman 3c707a7368 Fix HTTP mode reliability: add context timeouts to SSH collection
Critical fix for intermittent HTTP endpoint hangs identified by Codex analysis.

## Root Cause
SSH collection via getTemperatureViaSSH() had no timeout, causing HTTP
handlers to block indefinitely when sensors command hung. This held node-level
mutexes and rate limit slots, creating cascading failures where subsequent
requests queued indefinitely.

## Solution
- Thread request context through to SSH execution
- Add exec.CommandContext with 15s timeout (vs 30s HTTP client timeout)
- Create execCommandWithLimitsContext() to wrap SSH commands
- Ensures handlers always release locks and respond within deadline

## Impact
- HTTP temps endpoint now responds in ~70ms consistently
- Temperature data successfully collected and displayed in Pulse
- Eliminates 'context deadline exceeded' errors
- Prevents node gate deadlocks from slow/stuck SSH sessions

Related to Codex session 019a7e99-00fc-7903-afa3-01100baf47c6
2025-11-13 19:09:50 +00:00

179 lines
4.2 KiB
Go

package tempproxy
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// HTTPClient communicates with pulse-sensor-proxy via HTTPS
type HTTPClient struct {
baseURL string
authToken string
httpClient *http.Client
timeout time.Duration
}
// NewHTTPClient creates a new HTTP-based proxy client
func NewHTTPClient(baseURL, authToken string) *HTTPClient {
// Create HTTP client with TLS and reasonable timeouts
httpClient := &http.Client{
Timeout: defaultTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true, // Accept self-signed certificates from sensor-proxy
},
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
DisableCompression: false,
DisableKeepAlives: false,
MaxIdleConnsPerHost: 2,
},
}
return &HTTPClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
authToken: authToken,
httpClient: httpClient,
timeout: defaultTimeout,
}
}
// IsAvailable checks if the HTTP proxy is accessible
// For HTTP mode, we consider it available if URL and token are configured
func (c *HTTPClient) IsAvailable() bool {
return c.baseURL != "" && c.authToken != ""
}
// GetTemperature fetches temperature data from a node via HTTP
func (c *HTTPClient) GetTemperature(nodeHost string) (string, error) {
if !c.IsAvailable() {
return "", &ProxyError{
Type: ErrorTypeTransport,
Message: "HTTP proxy not configured",
Retryable: false,
}
}
// Build request URL
reqURL := fmt.Sprintf("%s/temps?node=%s", c.baseURL, url.QueryEscape(nodeHost))
// Create request
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return "", &ProxyError{
Type: ErrorTypeTransport,
Message: "failed to create HTTP request",
Retryable: false,
Wrapped: err,
}
}
// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.authToken))
req.Header.Set("Accept", "application/json")
// Execute request with retries
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
backoff := calculateBackoff(attempt)
time.Sleep(backoff)
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = &ProxyError{
Type: ErrorTypeTransport,
Message: fmt.Sprintf("HTTP request failed (attempt %d/%d)", attempt+1, maxRetries),
Retryable: true,
Wrapped: err,
}
continue
}
// Read response body
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = &ProxyError{
Type: ErrorTypeTransport,
Message: "failed to read response body",
Retryable: true,
Wrapped: err,
}
continue
}
// Check HTTP status
if resp.StatusCode == http.StatusUnauthorized {
return "", &ProxyError{
Type: ErrorTypeAuth,
Message: "authentication failed - invalid token",
Retryable: false,
}
}
if resp.StatusCode == http.StatusForbidden {
return "", &ProxyError{
Type: ErrorTypeAuth,
Message: "node not allowed by proxy",
Retryable: false,
}
}
if resp.StatusCode == http.StatusTooManyRequests {
lastErr = &ProxyError{
Type: ErrorTypeTransport,
Message: "rate limit exceeded",
Retryable: true,
}
continue
}
if resp.StatusCode != http.StatusOK {
return "", &ProxyError{
Type: ErrorTypeTransport,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)),
Retryable: resp.StatusCode >= 500,
}
}
// Parse JSON response
var jsonResp struct {
Node string `json:"node"`
Temperature string `json:"temperature"` // This is a JSON-encoded string
}
if err := json.Unmarshal(body, &jsonResp); err != nil {
return "", &ProxyError{
Type: ErrorTypeTransport,
Message: "failed to parse response JSON",
Retryable: false,
Wrapped: err,
}
}
// The temperature field contains JSON-encoded sensor data as a string
// Return it as-is since the caller expects raw JSON
return jsonResp.Temperature, nil
}
// All retries exhausted
if lastErr != nil {
return "", lastErr
}
return "", &ProxyError{
Type: ErrorTypeUnknown,
Message: "all retry attempts failed",
Retryable: false,
}
}