Files
zeekayandHanzo Dev c8a2045764 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers; omitzero is not applied, since it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:13:22 -07:00

152 lines
4.0 KiB
Go

// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package rpc
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
rpc "github.com/gorilla/rpc/v2/json2"
)
const (
maxRetries = 3
retryBaseWait = 500 * time.Millisecond // Increased from 100ms
)
// newHTTPClient creates an HTTP client for JSON-RPC requests.
// Uses short per-IP dial timeouts to handle DNS entries with mixed live/dead IPs.
// TLS verification can be disabled via LUX_INSECURE_TLS=1 for devnet/staging
// endpoints that use self-signed certificates.
func newHTTPClient() *http.Client {
skipTLS := os.Getenv("LUX_INSECURE_TLS") == "1"
return &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
TLSClientConfig: &tls.Config{InsecureSkipVerify: skipTLS},
DisableKeepAlives: true,
ForceAttemptHTTP2: true,
},
}
}
// CleanlyCloseBody drains and closes an HTTP response body to prevent
// HTTP/2 GOAWAY errors caused by closing bodies with unread data.
// See: https://github.com/golang/go/issues/46071
func CleanlyCloseBody(body io.ReadCloser) error {
if body == nil {
return nil
}
// Drain any remaining data to allow connection reuse
_, _ = io.Copy(io.Discard, body)
return body.Close()
}
// isRetryableError checks if an error is transient and worth retrying
func isRetryableError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
// EOF errors are often transient connection issues
if errors.Is(err, io.EOF) || strings.Contains(errStr, "EOF") {
return true
}
// Connection reset/refused are also transient
if strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "broken pipe") {
return true
}
return false
}
func SendJSONRequest(
ctx context.Context,
uri *url.URL,
method string,
params any,
reply any,
options ...Option,
) error {
log.Printf("[RPC-DEBUG] SendJSONRequest called: method=%s uri=%s", method, uri.String())
requestBodyBytes, err := rpc.EncodeClientRequest(method, params)
if err != nil {
return fmt.Errorf("failed to encode client params: %w", err)
}
ops := NewOptions(options)
uri.RawQuery = ops.queryParams.Encode()
var lastErr error
for attempt := range maxRetries {
if attempt > 0 {
// Exponential backoff: 100ms, 200ms, 400ms
waitTime := retryBaseWait * time.Duration(1<<(attempt-1))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(waitTime):
}
}
// Create fresh request for each attempt (body buffer is consumed)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
uri.String(),
bytes.NewBuffer(requestBodyBytes),
)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
request.Header = ops.headers
request.Header.Set("Content-Type", "application/json")
// Use a fresh HTTP client to avoid connection pooling issues
client := newHTTPClient()
resp, err := client.Do(request)
if err != nil {
lastErr = err
log.Printf("[RPC] Request attempt %d failed: %v (retryable=%v)", attempt+1, err, isRetryableError(err))
if isRetryableError(err) {
continue // Retry on transient errors
}
return fmt.Errorf("failed to issue request: %w", err)
}
if attempt > 0 {
log.Printf("[RPC] Request succeeded on attempt %d", attempt+1)
}
// Return an error for any non successful status code
if resp.StatusCode < 200 || resp.StatusCode > 299 {
CleanlyCloseBody(resp.Body)
return fmt.Errorf("received status code: %d", resp.StatusCode)
}
if err := rpc.DecodeClientResponse(resp.Body, reply); err != nil {
CleanlyCloseBody(resp.Body)
return fmt.Errorf("failed to decode client response: %w", err)
}
CleanlyCloseBody(resp.Body)
return nil
}
return fmt.Errorf("failed to issue request after %d retries: %w", maxRetries, lastErr)
}