fix(multiplayer): bound adapter HTTP calls

This commit is contained in:
Josh Creek
2026-09-02 19:12:40 +01:00
parent cbefa86c5c
commit 1bce603c33
5 changed files with 36 additions and 11 deletions
+2
View File
@@ -1644,3 +1644,5 @@ Allocated team and slot assignments are now immutable after signed admission. Ma
Per-IP API limiting now resolves the client behind the edge gateway instead of charging every player to the gateway's socket address. `X-Forwarded-For` is ignored unless the immediate peer belongs to an explicitly configured `--trusted-proxy-cidrs` range; trusted chains are walked from right to left past known proxies, while malformed/oversized chains fail closed to the immediate peer. The base deployment supplies private/CGNAT/ULA pod ranges under its edge-only ingress NetworkPolicy and calls out that production overlays should narrow them to the actual gateway CIDR. Tests cover spoofing from an untrusted peer, chained proxies, malformed input, invalid configuration, and independent clients behind one gateway.
Allocator probes now distinguish process liveness from useful progress. `/healthz` remains live during dependency outages, while `/readyz` starts unavailable and requires a fully successful provider-list, Ready-registration, and worker cycle within `--readiness-max-stale` (30 seconds in the base deployment). The Kubernetes/Agones HTTP path is bounded by `--provider-timeout=10s`, so an unavailable provider cannot leave readiness green indefinitely; startup rejects a freshness window shorter than the poll interval plus provider timeout, and the probe listener has its own header-read deadline. Boundary and HTTP tests cover startup, exact staleness, clock reversal, recovery, method rejection, and metrics coexistence.
The timeout boundary is enforced inside both network adapters as well as in the production allocator wiring: an `agones.Client` or game-server `Supervisor` constructed without an injected HTTP client now receives a ten-second client rather than Go's unbounded `http.DefaultClient`. This prevents alternate binaries, tests, and future callers from restoring an infinite GameServer, roster, registration, or SDK wait by omission.
+12 -9
View File
@@ -42,6 +42,8 @@ type Client struct {
WorkloadTokenTTL time.Duration
}
const DefaultHTTPTimeout = 10 * time.Second
type AllocatedServer struct {
Allocation domain.Allocation
Endpoint string
@@ -108,9 +110,7 @@ func (c Client) RecoverAllocation(ctx context.Context, request domain.Allocation
if request.AllocationID == "" || request.MatchID == "" || now.IsZero() {
return AllocatedServer{}, false, domain.ErrAllocationInput
}
if c.HTTP == nil {
c.HTTP = http.DefaultClient
}
c.HTTP = c.httpClient()
base, err := c.endpoint()
if err != nil {
return AllocatedServer{}, false, err
@@ -166,9 +166,7 @@ func (c Client) RecoverAllocation(ctx context.Context, request domain.Allocation
// allocator registry. Compatibility fields must be present as Fleet labels;
// malformed Ready objects fail closed instead of creating selectable capacity.
func (c Client) ListReadyServers(ctx context.Context) ([]domain.ReadyServer, error) {
if c.HTTP == nil {
c.HTTP = http.DefaultClient
}
c.HTTP = c.httpClient()
base, err := c.endpoint()
if err != nil {
return nil, err
@@ -213,9 +211,7 @@ func readyServerFromGameServer(name string, labels map[string]string) (domain.Re
}
func (c Client) Allocate(ctx context.Context, request domain.AllocationRequest, labels map[string]string, now time.Time) (AllocatedServer, error) {
if c.HTTP == nil {
c.HTTP = http.DefaultClient
}
c.HTTP = c.httpClient()
base, err := c.endpoint()
if err != nil {
return AllocatedServer{}, err
@@ -305,6 +301,13 @@ func (c Client) endpoint() (string, error) {
return strings.TrimRight(c.BaseURL, "/"), nil
}
func (c Client) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: DefaultHTTPTimeout}
}
func selectPort(ports []struct {
Name string `json:"name"`
Port int `json:"port"`
+7
View File
@@ -17,6 +17,13 @@ func request() domain.AllocationRequest {
return domain.AllocationRequest{AllocationID: "allocation-1", MatchID: "match-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}
}
func TestClientDefaultHTTPTransportHasRequestDeadline(t *testing.T) {
client := (Client{}).httpClient()
if client == http.DefaultClient || client.Timeout != DefaultHTTPTimeout || client.Timeout <= 0 {
t.Fatalf("default HTTP client timeout = %s", client.Timeout)
}
}
func TestAllocateRejectsRankedRequestsWithoutRegisteredArena(t *testing.T) {
client := Client{BaseURL: "http://127.0.0.1:1", Namespace: "games"}
for _, path := range []string{"", "res://scenes/arena_01_elevated.tscn", "res://forged.tscn"} {
+5 -2
View File
@@ -106,7 +106,10 @@ type Supervisor struct {
lastGameServer GameServer
}
const DefaultDrainGrace = 285 * time.Second
const (
DefaultDrainGrace = 285 * time.Second
DefaultHTTPTimeout = 10 * time.Second
)
func New(config Config) (*Supervisor, error) {
if len(config.Command) == 0 || config.Command[0] == "" {
@@ -131,7 +134,7 @@ func New(config Config) (*Supervisor, error) {
return nil, fmt.Errorf("unsupported transport %q", config.Transport)
}
if config.HTTPClient == nil {
config.HTTPClient = http.DefaultClient
config.HTTPClient = &http.Client{Timeout: DefaultHTTPTimeout}
}
if (config.DrainURL == "") != (config.DrainToken == "") {
return nil, fmt.Errorf("drain URL and token must be configured together")
+10
View File
@@ -13,6 +13,16 @@ import (
"time"
)
func TestSupervisorDefaultHTTPClientHasRequestDeadline(t *testing.T) {
supervisor, err := New(Config{Command: []string{"game-server"}})
if err != nil {
t.Fatal(err)
}
if supervisor.client == http.DefaultClient || supervisor.client.Timeout != DefaultHTTPTimeout || supervisor.client.Timeout <= 0 {
t.Fatalf("default HTTP client timeout = %s", supervisor.client.Timeout)
}
}
func TestWithAllocatedConfigOverridesAuthoritativeChildFlags(t *testing.T) {
command := []string{
"game-server", "--", "--allocated-mode", "--match-id=stale-match",