connector impl

This commit is contained in:
Ilia Denisov
2026-03-14 21:11:51 +02:00
committed by GitHub
parent c2d2cebe3e
commit ac3ed31a23
9 changed files with 863 additions and 6 deletions
+141 -6
View File
@@ -7,11 +7,15 @@ import (
"errors"
"fmt"
"galaxy/connector"
"galaxy/model/client"
"galaxy/model/report"
"io"
"math/rand/v2"
"net"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
)
@@ -21,6 +25,10 @@ const (
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
@@ -36,6 +44,9 @@ var defaultRetryCaps = []time.Duration{
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
@@ -77,6 +88,12 @@ func newHTTPClient(connectTimeout, responseTimeout time.Duration) *http.Client {
}
}
// 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
@@ -176,10 +193,110 @@ func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) {
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)
// 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 {
@@ -211,9 +328,27 @@ func (h *httpConnector) doRequest(ctx context.Context, relativePath string) (*ht
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := h.httpClient.Do(req)
resp, err := h.doHTTP(req)
if err == nil {
return resp, 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