Files
galaxy-game/pkg/connector/http/http.go
T
2026-03-16 19:52:02 +02:00

414 lines
11 KiB
Go

// Package implements "galaxy/connector.Connector" interface with HTTP REST API protocol
package http
import (
"context"
"encoding/json"
"errors"
"fmt"
"galaxy/connector"
gerr "galaxy/error"
"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, gerr.WrapService(fmt.Errorf("parse backend URL %q: %w", backendURL, 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
}
// isConnectionError reports transport-level connectivity failures that should
// be surfaced as connection errors instead of service contract errors.
func isConnectionError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
if isConnectTimeout(err) {
return true
}
var urlErr *url.Error
if errors.As(err, &urlErr) {
err = urlErr.Err
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) {
return true
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
return false
}
func classifyConnectorError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
if gerr.IsConnection(err) || gerr.IsService(err) {
return err
}
if isConnectionError(err) {
return gerr.WrapConnection(err)
}
return gerr.WrapService(err)
}
// 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, classifyConnectorError(fmt.Errorf("request versions from backend: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, classifyConnectorError(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, classifyConnectorError(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, classifyConnectorError(fmt.Errorf("download version artifact: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, classifyConnectorError(fmt.Errorf("download version artifact: unexpected status code %d", resp.StatusCode))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, classifyConnectorError(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{}, classifyConnectorError(fmt.Errorf("request report from backend: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return report.Report{}, classifyConnectorError(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{}, classifyConnectorError(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
}