361 lines
9.8 KiB
Go
361 lines
9.8 KiB
Go
// 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
|
|
}
|