// Package lobbyclient provides the trusted-internal Lobby REST client // Runtime Manager uses to fetch ancillary game metadata for diagnostics. // // The client is intentionally minimal: the GetGame fetch is ancillary // diagnostics because the start envelope already carries the only // required field (`image_ref`). A failed call surfaces as // `ports.ErrLobbyUnavailable` so callers can distinguish "not found" // from transport faults and continue without aborting the start // operation. package lobbyclient import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "galaxy/rtmanager/internal/ports" ) const ( getGamePathSuffix = "/api/v1/internal/games/%s" ) // Config configures one HTTP-backed Lobby internal client. type Config struct { // BaseURL stores the absolute base URL of the Lobby internal HTTP // listener (e.g. `http://lobby:8095`). BaseURL string // RequestTimeout bounds one outbound lookup request. RequestTimeout time.Duration } // Client resolves Lobby game records through the trusted internal HTTP // API. type Client struct { baseURL string requestTimeout time.Duration httpClient *http.Client closeIdleConnections func() } type gameRecordEnvelope struct { GameID string `json:"game_id"` Status string `json:"status"` TargetEngineVersion string `json:"target_engine_version"` } type errorEnvelope struct { Error *errorBody `json:"error"` } type errorBody struct { Code string `json:"code"` Message string `json:"message"` } // NewClient constructs a Lobby internal client that uses // repository-standard HTTP transport instrumentation through otelhttp. // The cloned default transport keeps the production wiring isolated // from caller-provided transports. func NewClient(cfg Config) (*Client, error) { transport, ok := http.DefaultTransport.(*http.Transport) if !ok { return nil, errors.New("new lobby internal client: default transport is not *http.Transport") } cloned := transport.Clone() return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections) } func newClient(cfg Config, httpClient *http.Client, closeIdleConnections func()) (*Client, error) { switch { case strings.TrimSpace(cfg.BaseURL) == "": return nil, errors.New("new lobby internal client: base URL must not be empty") case cfg.RequestTimeout <= 0: return nil, errors.New("new lobby internal client: request timeout must be positive") case httpClient == nil: return nil, errors.New("new lobby internal client: http client must not be nil") } parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")) if err != nil { return nil, fmt.Errorf("new lobby internal client: parse base URL: %w", err) } if parsed.Scheme == "" || parsed.Host == "" { return nil, errors.New("new lobby internal client: base URL must be absolute") } return &Client{ baseURL: parsed.String(), requestTimeout: cfg.RequestTimeout, httpClient: httpClient, closeIdleConnections: closeIdleConnections, }, nil } // Close releases idle HTTP connections owned by the client transport. // Call once on shutdown. func (client *Client) Close() error { if client == nil || client.closeIdleConnections == nil { return nil } client.closeIdleConnections() return nil } // GetGame returns the Lobby game record for gameID. It maps Lobby's // `404 not_found` to `ports.ErrLobbyGameNotFound`; every other failure // (transport, timeout, non-2xx response) maps to // `ports.ErrLobbyUnavailable` wrapped with the original error so callers // keep the diagnostic detail. func (client *Client) GetGame(ctx context.Context, gameID string) (ports.LobbyGameRecord, error) { if client == nil || client.httpClient == nil { return ports.LobbyGameRecord{}, errors.New("lobby get game: nil client") } if ctx == nil { return ports.LobbyGameRecord{}, errors.New("lobby get game: nil context") } if err := ctx.Err(); err != nil { return ports.LobbyGameRecord{}, err } if strings.TrimSpace(gameID) == "" { return ports.LobbyGameRecord{}, errors.New("lobby get game: game id must not be empty") } payload, statusCode, err := client.doRequest(ctx, http.MethodGet, fmt.Sprintf(getGamePathSuffix, url.PathEscape(gameID))) if err != nil { return ports.LobbyGameRecord{}, fmt.Errorf("%w: %w", ports.ErrLobbyUnavailable, err) } switch statusCode { case http.StatusOK: var envelope gameRecordEnvelope if err := decodeJSONPayload(payload, &envelope); err != nil { return ports.LobbyGameRecord{}, fmt.Errorf("%w: decode success response: %w", ports.ErrLobbyUnavailable, err) } if strings.TrimSpace(envelope.GameID) == "" { return ports.LobbyGameRecord{}, fmt.Errorf("%w: success response missing game_id", ports.ErrLobbyUnavailable) } return ports.LobbyGameRecord{ GameID: envelope.GameID, Status: envelope.Status, TargetEngineVersion: envelope.TargetEngineVersion, }, nil case http.StatusNotFound: return ports.LobbyGameRecord{}, ports.ErrLobbyGameNotFound default: errorCode := decodeErrorCode(payload) if errorCode != "" { return ports.LobbyGameRecord{}, fmt.Errorf("%w: unexpected status %d (error_code=%s)", ports.ErrLobbyUnavailable, statusCode, errorCode) } return ports.LobbyGameRecord{}, fmt.Errorf("%w: unexpected status %d", ports.ErrLobbyUnavailable, statusCode) } } func (client *Client) doRequest(ctx context.Context, method, requestPath string) ([]byte, int, error) { attemptCtx, cancel := context.WithTimeout(ctx, client.requestTimeout) defer cancel() req, err := http.NewRequestWithContext(attemptCtx, method, client.baseURL+requestPath, nil) if err != nil { return nil, 0, fmt.Errorf("build request: %w", err) } req.Header.Set("Accept", "application/json") resp, err := client.httpClient.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, 0, fmt.Errorf("read response body: %w", err) } return body, resp.StatusCode, nil } // decodeJSONPayload tolerantly decodes a JSON object; unknown fields // are ignored so additive Lobby schema changes do not break us. func decodeJSONPayload(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(payload)) if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("unexpected trailing JSON input") } return err } return nil } func decodeErrorCode(payload []byte) string { if len(payload) == 0 { return "" } var envelope errorEnvelope if err := json.Unmarshal(payload, &envelope); err != nil { return "" } if envelope.Error == nil { return "" } return envelope.Error.Code } // Compile-time assertion: Client implements ports.LobbyInternalClient. var _ ports.LobbyInternalClient = (*Client)(nil)