package main import ( "bytes" "fmt" "io" "net/http" "time" ) // forwardClient is deliberately short-tempered. A bet or cash-out is only // useful while the round is still open, so a request that cannot be delivered // promptly should fail loudly rather than land after the moment has passed. var forwardClient = &http.Client{Timeout: 3 * time.Second} // forwardToLeader proxies a mutation to whichever instance drives the game. // // Only one instance holds a game's authoritative round state — who is in, at // what stake, and at which tick each cash-out landed. Serving a bet from a // follower's idle copy would either fail or, worse, create a second version of // the round. So the follower relays the request and returns the leader's // answer verbatim. // // The caller's Authorization header travels with it. That works because // sessions live in Redis, so the leader can validate a token issued by any // instance in the fleet. // // It reports whether the request was handled here. func (s *server) forwardToLeader(w http.ResponseWriter, r *http.Request, game string, body []byte) bool { hub, ok := s.hubs[game] if !ok { writeErr(w, http.StatusNotFound, "no such game") return true } if hub.Leading() { return false // this instance owns the round; handle it locally } leader, err := s.node.LeaderOf(r.Context(), game) if err != nil { writeErr(w, http.StatusServiceUnavailable, "cannot locate the game leader") return true } if leader.Address == "" { // Between leaders: a lease has expired and the next campaign has not // landed yet. This resolves within a couple of seconds on its own. writeErr(w, http.StatusServiceUnavailable, "this game is changing hands; try again in a moment") return true } url := fmt.Sprintf("http://%s%s", leader.Address, r.URL.Path) req, err := http.NewRequestWithContext(r.Context(), r.Method, url, bytes.NewReader(body)) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return true } req.Header.Set("Content-Type", "application/json") if auth := r.Header.Get("Authorization"); auth != "" { req.Header.Set("Authorization", auth) } // Mark the hop so a routing mistake shows up as an explicit loop error // rather than as instances bouncing a request between themselves. if r.Header.Get("X-Arcade-Forwarded") != "" { writeErr(w, http.StatusLoopDetected, "request was forwarded twice; the cluster disagrees about the leader") return true } req.Header.Set("X-Arcade-Forwarded", s.node.ID) res, err := forwardClient.Do(req) if err != nil { writeErr(w, http.StatusServiceUnavailable, "the instance running this game did not respond") return true } defer res.Body.Close() payload, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { writeErr(w, http.StatusBadGateway, "truncated response from the game leader") return true } w.Header().Set("Content-Type", "application/json") w.WriteHeader(res.StatusCode) _, _ = w.Write(payload) return true } // readBody buffers a request body so it can be both parsed locally and // forwarded if this instance turns out not to own the game. func readBody(r *http.Request) ([]byte, error) { defer r.Body.Close() return io.ReadAll(io.LimitReader(r.Body, 1<<20)) }