connector impl
This commit is contained in:
+141
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user