Files
galaxy-game/lobby/internal/adapters/gmclient/client.go
T
2026-04-25 23:20:55 +02:00

175 lines
5.4 KiB
Go

// Package gmclient provides the HTTP adapter for the ports.GMClient
// surface. It implements the registration path
// `POST /api/v1/internal/games/{game_id}/register-runtime` and the
// liveness probe `GET /api/v1/internal/healthz` used by the voluntary
// resume flow.
//
// Every transport-level failure (timeout, network error, non-2xx
// response) is wrapped with ports.ErrGMUnavailable so callers can
// detect the GM-unavailable case via errors.Is and follow the
// `lobby.runtime_paused_after_start` branch or the
// `service_unavailable` branch documented in the
// README Game Start Flow.
package gmclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"galaxy/lobby/internal/ports"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
// Client implements ports.GMClient against the trusted internal HTTP
// surface of Game Master.
type Client struct {
baseURL string
httpClient *http.Client
}
// Config groups the construction parameters of Client.
type Config struct {
// BaseURL is the absolute root URL of Game Master (no trailing
// slash required).
BaseURL string
// Timeout bounds one round trip including TLS handshake. It must
// be positive.
Timeout time.Duration
}
// Validate reports whether cfg stores a usable Client configuration.
func (cfg Config) Validate() error {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return errors.New("gm client base url must not be empty")
case cfg.Timeout <= 0:
return errors.New("gm client timeout must be positive")
default:
return nil
}
}
// NewClient constructs a Client from cfg. The transport is wrapped with
// otelhttp.NewTransport so traces propagate to Game Master.
func NewClient(cfg Config) (*Client, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new gm client: %w", err)
}
httpClient := &http.Client{
Timeout: cfg.Timeout,
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
return &Client{
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
httpClient: httpClient,
}, nil
}
// registerRuntimeBody mirrors the JSON body Lobby sends to Game Master.
// The shape is owned by Lobby for the Game Master is expected to
// accept it as-is when it implements the receiving handler.
type registerRuntimeBody struct {
ContainerID string `json:"container_id"`
EngineEndpoint string `json:"engine_endpoint"`
TargetEngineVersion string `json:"target_engine_version"`
TurnSchedule string `json:"turn_schedule"`
}
// RegisterGame issues
// POST /api/v1/internal/games/{game_id}/register-runtime against Game
// Master. Any non-success outcome (validation error, transport error,
// timeout, non-2xx response) is wrapped with ports.ErrGMUnavailable so
// the caller can branch on errors.Is(err, ports.ErrGMUnavailable).
func (client *Client) RegisterGame(ctx context.Context, request ports.RegisterGameRequest) error {
if client == nil || client.httpClient == nil {
return errors.New("register game: nil client")
}
if ctx == nil {
return errors.New("register game: nil context")
}
if err := request.Validate(); err != nil {
return fmt.Errorf("register game: %w", err)
}
endpoint := client.baseURL + "/api/v1/internal/games/" + url.PathEscape(request.GameID.String()) + "/register-runtime"
body := registerRuntimeBody{
ContainerID: request.ContainerID,
EngineEndpoint: request.EngineEndpoint,
TargetEngineVersion: request.TargetEngineVersion,
TurnSchedule: request.TurnSchedule,
}
encoded, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("register game: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(encoded))
if err != nil {
return fmt.Errorf("register game: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := client.httpClient.Do(req)
if err != nil {
return fmt.Errorf("register game: %w", errors.Join(ports.ErrGMUnavailable, err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf(
"register game: unexpected status %d: %w",
resp.StatusCode, ports.ErrGMUnavailable,
)
}
return nil
}
// Ping issues GET /api/v1/internal/healthz against Game Master. Any
// non-success outcome (validation error, transport error, timeout,
// non-2xx response) is wrapped with ports.ErrGMUnavailable so the
// caller can branch on errors.Is(err, ports.ErrGMUnavailable). Stage
// 16 voluntary resume uses this method as the liveness gate before
// transitioning a paused game back to running.
func (client *Client) Ping(ctx context.Context) error {
if client == nil || client.httpClient == nil {
return errors.New("ping: nil client")
}
if ctx == nil {
return errors.New("ping: nil context")
}
endpoint := client.baseURL + "/api/v1/internal/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("ping: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := client.httpClient.Do(req)
if err != nil {
return fmt.Errorf("ping: %w", errors.Join(ports.ErrGMUnavailable, err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf(
"ping: unexpected status %d: %w",
resp.StatusCode, ports.ErrGMUnavailable,
)
}
return nil
}
// Compile-time interface assertion.
var _ ports.GMClient = (*Client)(nil)