// Package implements "galaxy/connector.Connector" interface with HTTP REST API protocol package http import ( "context" "encoding/json" "errors" "fmt" "galaxy/connector" "galaxy/model/client" "galaxy/model/report" "io" "math/rand/v2" "net" "net/http" "net/url" "path" "strconv" "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" // fetchReportPath is backend endpoint path used to load game report for a specific turn number. fetchReportPath = "api/v1/report" // fetchReportPlayer is a temporary player identifier until UI passes actor identity explicitly. fetchReportPlayer = "Race_01" // 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, } // errMovedPermanentlyWithoutLocation reports an invalid redirect response. var errMovedPermanentlyWithoutLocation = errors.New("server returned 301 response without Location header") 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, } } // doNotFollowRedirect keeps redirect handling inside doRequest so retry budget // and jitter stay under connector control. func doNotFollowRedirect(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } 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 } // DownloadVersion retrieves a version artifact from backend storage. // urlOrPath may be either a backend-relative path or a fully qualified URL. func (h *httpConnector) DownloadVersion(urlOrPath string) ([]byte, error) { resp, err := h.doRequest(h.requestContext(), urlOrPath) if err != nil { return nil, fmt.Errorf("download version artifact: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("download version artifact: unexpected status code %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read version artifact body: %w", err) } return body, nil } // FetchReport asynchronously loads a report for turn from backend and invokes callback once with the result. func (h *httpConnector) FetchReport(_ client.GameID, turn uint, callback func(report.Report, error)) { go func() { rep, err := h.fetchReport(turn) if callback != nil { callback(rep, err) } }() } // fetchReport loads a report for turn from backend using the temporary player identifier. func (h *httpConnector) fetchReport(turn uint) (report.Report, error) { resp, err := h.doRequest(h.requestContext(), fetchReportRequestPath(turn)) if err != nil { return report.Report{}, fmt.Errorf("request report from backend: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return report.Report{}, fmt.Errorf("request report from backend: unexpected status code %d", resp.StatusCode) } var rep report.Report if err := json.NewDecoder(resp.Body).Decode(&rep); err != nil { return report.Report{}, fmt.Errorf("decode report response: %w", err) } return rep, nil } // fetchReportRequestPath builds the report endpoint with required query parameters. func fetchReportRequestPath(turn uint) string { values := url.Values{} values.Set("player", fetchReportPlayer) values.Set("turn", strconv.FormatUint(uint64(turn), 10)) return fetchReportPath + "?" + values.Encode() } // resolveRequestURL returns either the fully qualified request URL as-is or // composes a backend-relative path with connector backendURL. func (h *httpConnector) resolveRequestURL(urlOrPath string) (*url.URL, error) { requestURL, err := url.Parse(urlOrPath) if err != nil { return nil, fmt.Errorf("parse request URL %q: %w", urlOrPath, err) } if requestURL.IsAbs() { return requestURL, nil } resolvedURL := *h.backendURL resolvedURL.Path = path.Join(resolvedURL.Path, requestURL.Path) if requestURL.RawQuery != "" { resolvedURL.RawQuery = requestURL.RawQuery } if requestURL.Fragment != "" { resolvedURL.Fragment = requestURL.Fragment } return &resolvedURL, nil } // doHTTP executes a single HTTP exchange without the standard client redirect handling. func (h *httpConnector) doHTTP(req *http.Request) (*http.Response, error) { client := h.httpClient if client == nil { client = newHTTPClient(connectTimeout, responseTimeout) } noRedirectClient := *client noRedirectClient.CheckRedirect = doNotFollowRedirect return noRedirectClient.Do(req) } // doRequest performs a GET request for either a backend-relative endpoint or a // fully qualified URL with the passed context. func (h *httpConnector) doRequest(ctx context.Context, urlOrPath string) (*http.Response, error) { requestURL, err := h.resolveRequestURL(urlOrPath) if err != nil { return nil, err } 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.doHTTP(req) if err == nil { if resp.StatusCode != http.StatusMovedPermanently { return resp, nil } location := resp.Header.Get("Location") resp.Body.Close() if location == "" { return nil, fmt.Errorf("request %q: %w", requestURL.Redacted(), errMovedPermanentlyWithoutLocation) } if attempt == len(retryCaps) { return nil, fmt.Errorf("request %q: exhausted attempts following redirect to %q", requestURL.Redacted(), location) } redirectURL, err := requestURL.Parse(location) if err != nil { return nil, fmt.Errorf("resolve redirect location %q for request %q: %w", location, requestURL.Redacted(), err) } requestURL = redirectURL continue } if !isConnectTimeout(err) { return nil, err } lastErr = err } return nil, lastErr }