// Package implements "galaxy/connector.Connector" interface with HTTP REST API protocol 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) { u, err := url.Parse(backendURL) if err != nil { return nil, err } 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 }