http connector first impl

This commit is contained in:
Ilia Denisov
2026-03-12 23:45:06 +02:00
committed by GitHub
parent f985370089
commit 9adadc3bbf
13 changed files with 962 additions and 41 deletions
+201
View File
@@ -3,12 +3,46 @@ package http
import (
"context"
"encoding/json"
"errors"
"fmt"
"galaxy/connector"
"math/rand/v2"
"net"
"net/http"
"net/url"
"path"
"strings"
"time"
)
const (
// checkConnectionPath is backend endpoint path used to test server reachability.
checkConnectionPath = "api/v1/status"
// checkVersionPath is backend endpoint path used to load available app versions.
checkVersionPath = "api/v1/versions"
// connectTimeout is max time for establishing TCP connection.
connectTimeout = 3 * time.Second
// responseTimeout is max time for waiting response headers from backend.
responseTimeout = 3 * time.Second
)
// defaultRetryCaps defines connect-timeout retry caps for full-jitter backoff.
var defaultRetryCaps = []time.Duration{
5 * time.Second,
15 * time.Second,
30 * time.Second,
60 * time.Second,
}
type httpConnector struct {
ctx context.Context
backendURL *url.URL // HTTP REST API Server URL
httpClient *http.Client
retryCaps []time.Duration
jitterFn func(time.Duration) time.Duration
sleepFn func(context.Context, time.Duration) error
}
func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, error) {
@@ -19,6 +53,173 @@ func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, e
h := &httpConnector{
ctx: ctx,
backendURL: u,
httpClient: newHTTPClient(connectTimeout, responseTimeout),
retryCaps: append([]time.Duration(nil), defaultRetryCaps...),
jitterFn: fullJitter,
sleepFn: sleepWithContext,
}
return h, nil
}
// newHTTPClient builds dedicated HTTP client with separate timeouts
// for connect and response phases.
func newHTTPClient(connectTimeout, responseTimeout time.Duration) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = (&net.Dialer{
Timeout: connectTimeout,
KeepAlive: 30 * time.Second,
}).DialContext
transport.TLSHandshakeTimeout = connectTimeout
transport.ResponseHeaderTimeout = responseTimeout
return &http.Client{
Transport: transport,
}
}
func (h *httpConnector) requestContext() context.Context {
if h.ctx != nil {
return h.ctx
}
return context.Background()
}
// fullJitter calculates random wait duration in [0, cap].
func fullJitter(cap time.Duration) time.Duration {
if cap <= 0 {
return 0
}
return time.Duration(rand.Int64N(cap.Nanoseconds() + 1))
}
// sleepWithContext blocks for the given duration or until context cancellation.
func sleepWithContext(ctx context.Context, d time.Duration) error {
if d <= 0 {
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// isConnectTimeout returns true for dial and TLS-handshake timeout errors.
func isConnectTimeout(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
var urlErr *url.Error
if errors.As(err, &urlErr) {
err = urlErr.Err
}
if strings.Contains(err.Error(), "timeout awaiting response headers") {
return false
}
if strings.Contains(err.Error(), "TLS handshake timeout") {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) {
return opErr.Op == "dial" && opErr.Timeout()
}
return false
}
// CheckConnection probes backend status endpoint and reports whether server is reachable.
func (h *httpConnector) CheckConnection() bool {
resp, err := h.doRequest(h.requestContext(), checkConnectionPath)
if err != nil {
return false
}
defer resp.Body.Close()
return true
}
// CheckVersion loads available app versions from backend and returns parsed version metadata.
func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) {
resp, err := h.doRequest(h.requestContext(), checkVersionPath)
if err != nil {
return nil, fmt.Errorf("request versions from backend: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("request versions from backend: unexpected status code %d", resp.StatusCode)
}
var versions []connector.VersionInfo
if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
return nil, fmt.Errorf("decode versions response: %w", err)
}
return versions, nil
}
// doRequest performs GET request for a backend relative endpoint with passed context.
func (h *httpConnector) doRequest(ctx context.Context, relativePath string) (*http.Response, error) {
requestURL := *h.backendURL
requestURL.Path = path.Join(requestURL.Path, relativePath)
retryCaps := h.retryCaps
if retryCaps == nil {
retryCaps = defaultRetryCaps
}
jitterFn := h.jitterFn
if jitterFn == nil {
jitterFn = fullJitter
}
sleepFn := h.sleepFn
if sleepFn == nil {
sleepFn = sleepWithContext
}
var lastErr error
for attempt := 0; attempt <= len(retryCaps); attempt++ {
if attempt > 0 {
delay := jitterFn(retryCaps[attempt-1])
if delay < 0 {
delay = 0
}
if err := sleepFn(ctx, delay); err != nil {
return nil, err
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := h.httpClient.Do(req)
if err == nil {
return resp, nil
}
if !isConnectTimeout(err) {
return nil, err
}
lastErr = err
}
return nil, lastErr
}