Files
gateway/peer_resolver_test.go
zooqueen 3730e4b9c9 fix(boot): non-fatal ZAP backend dial — gateway boots with backends absent
Since the HIP-0110 ZAP-relay rearchitecture, cmd/gateway/main's
dialBackends eagerly dialed cloud:9090 / base:9091 at startup and
os.Exit(1)'d on any failure. When those backends are not deployed (the
steady state in prod), every gateway image past v2.14.1 crashlooped —
freezing the gateway on v2.14.1 and blocking the merged CORS fix and the
billing gate. HTTP serving (what api.hanzo.ai actually needs) worked fine.

Decomplect boot from backend reachability:

  - startNode (was dialBackends) only starts the ZAP node. The single
    fatal case left is a genuine local error — failure to bind the
    ephemeral port. No dialing at startup.
  - peerResolver (new, peer_resolver.go) lazily resolves a backend
    address to its handshake-learned peerID, dialing on first use with
    single-flight, caching once connected. ConnectDirectID is idempotent,
    so a healthy backend is dialed exactly once and ZAP is unchanged.
  - NewLazyPicker best-effort warms both backends (failure -> WARN, not
    fatal) and returns the forward.PeerPicker. A dead backend makes the
    picker return "" -> node.Call yields a per-request "peer not found"
    error that forward.Relay surfaces as an HTTP error, never a crash.
    The dial is retried on the next matching request (heals with no
    restart once the backend appears).
  - isBasePath is the single definition of the /v1/base/* split, shared
    by the static pickPeer (tests) and the lazy picker (prod).
  - RelayDeps.Pick lets main pass the lazy picker; nil falls back to the
    static pickPeer for the pre-existing e2e test.

Tests (peer_resolver_test.go): boot succeeds with backends absent (no
hang/crash), healthz still serves 200, picker returns "" for dead
backends, ZAP routes+caches correctly when backends exist, and recovers
when a backend comes up. All pass under -race.
2026-06-19 22:33:58 -07:00

242 lines
8.5 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
package gateway
// Tests for the lazy peer resolver (peer_resolver.go) — the core of the
// HIP-0110 boot fix. The contract under test:
//
// - NewLazyPicker boots with the ZAP backends ABSENT: warming a dead
// address must NOT panic, NOT block past the dial timeout, and NOT be
// fatal. The returned picker is usable.
// - A picker over dead backends returns "" for every path (so node.Call
// gets a benign "peer not found" instead of the process dying).
// - RegisterRelay + BuildApp succeed with a lazy picker over dead
// backends — i.e. the whole gateway wiring stands up with no backend.
// - When a backend IS reachable, the picker resolves its handshake-
// learned peerID and caches it (resolve is idempotent and a no-op dial
// the second time), so a healthy backend behaves exactly like the old
// eager path. /v1/base/* routes to base, everything else to cloud.
import (
"encoding/json"
"io"
"net/http"
"strconv"
"testing"
"time"
luxlog "github.com/luxfi/log"
zaplib "github.com/luxfi/zap"
"github.com/luxfi/zap/forward"
)
// deadAddr is a loopback address with nothing listening. ConnectDirectID
// fails fast against it (connection refused), which is the steady-state the
// gateway must boot through when cloud:9090 / base:9091 don't exist.
const deadAddr = "127.0.0.1:1" // port 1 is privileged + unbound -> refused
// startedNode brings up a NoDiscovery ZAP node on an ephemeral port for a
// test and registers Stop() cleanup. Mirrors the production node config.
func startedNode(t *testing.T, id string) *zaplib.Node {
t.Helper()
n := zaplib.NewNode(zaplib.NodeConfig{NodeID: id, Port: 0, NoDiscovery: true})
if err := n.Start(); err != nil {
t.Fatalf("%s start: %v", id, err)
}
t.Cleanup(n.Stop)
return n
}
// TestNewLazyPicker_BootsWithBackendsAbsent is the headline boot-fix test:
// constructing the picker against two dead backends must return without
// panicking or hanging, and the picker must yield "" (not crash) for any
// path. This is exactly the prod condition that previously os.Exit(1)'d.
func TestNewLazyPicker_BootsWithBackendsAbsent(t *testing.T) {
node := startedNode(t, "gateway")
done := make(chan forward.PeerPicker, 1)
go func() {
// NewLazyPicker warms both (dead) backends best-effort, then returns.
done <- NewLazyPicker(node, luxlog.NewNoOpLogger(), deadAddr, deadAddr)
}()
var pick forward.PeerPicker
select {
case pick = <-done:
case <-time.After(20 * time.Second):
t.Fatal("NewLazyPicker blocked with backends absent — boot would hang/crash")
}
if pick == nil {
t.Fatal("NewLazyPicker returned nil picker")
}
// Every route resolves to "" (dead backend) — never a panic.
for _, path := range []string{"/v1/chat/completions", "/v1/base/records", "/"} {
if got := pick(path); got != "" {
t.Errorf("pick(%q) over dead backend = %q, want \"\"", path, got)
}
}
}
// TestRegisterRelay_BootsWithLazyPickerNoBackends proves the full relay
// wiring (RegisterRelay) + the liveness HTTP app (BuildApp) stand up when
// the backends are absent — no error, no panic. The pod would boot and
// serve healthz in this state.
func TestRegisterRelay_BootsWithLazyPickerNoBackends(t *testing.T) {
node := startedNode(t, "gateway")
pick := NewLazyPicker(node, luxlog.NewNoOpLogger(), deadAddr, deadAddr)
if err := RegisterRelay(RelayDeps{
Logger: luxlog.NewNoOpLogger(),
Node: node,
Pick: pick,
Auth: AuthConfig{Enabled: false},
}); err != nil {
t.Fatalf("RegisterRelay with absent backends returned error: %v", err)
}
app, err := BuildApp(RouterDeps{
Logger: luxlog.NewNoOpLogger(),
ZAPNode: node,
CloudAddr: deadAddr,
BaseAddr: deadAddr,
})
if err != nil {
t.Fatalf("BuildApp with absent backends returned error: %v", err)
}
// healthz must serve 200 — the liveness surface the k8s probe needs.
// app.Fiber().Test drives the router in-memory (no real listener).
req, _ := http.NewRequest(http.MethodGet, "http://gateway/healthz", nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("healthz Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("healthz status = %d, want 200 (HTTP must serve with backends absent)", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var got map[string]string
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("healthz body not JSON: %q (%v)", body, err)
}
if got["status"] != "ok" {
t.Errorf("healthz body status = %q, want ok", got["status"])
}
}
// startedNodeOnPort brings up a NoDiscovery node on an explicit port so the
// test knows the dial address. Cleanup registered.
func startedNodeOnPort(t *testing.T, id string, port int) *zaplib.Node {
t.Helper()
n := zaplib.NewNode(zaplib.NodeConfig{NodeID: id, Port: port, NoDiscovery: true})
if err := n.Start(); err != nil {
t.Fatalf("%s start on :%d: %v", id, port, err)
}
t.Cleanup(n.Stop)
return n
}
// TestLazyPicker_ResolvesAndRoutesWithLiveBackends proves ZAP still works
// when the backends exist: the lazy picker dials each live backend on first
// use, learns its peerID, routes /v1/base/* to base and everything else to
// cloud, and caches the result (a second resolve does not change the id).
func TestLazyPicker_ResolvesAndRoutesWithLiveBackends(t *testing.T) {
gw := startedNode(t, "gateway")
cloudPort := reservePort(t)
basePort := reservePort(t)
cloud := startedNodeOnPort(t, "cloud-be", cloudPort)
base := startedNodeOnPort(t, "base-be", basePort)
cloudAddr := "127.0.0.1:" + strconv.Itoa(cloudPort)
baseAddr := "127.0.0.1:" + strconv.Itoa(basePort)
r := newPeerResolver(gw, luxlog.NewNoOpLogger())
pick := r.picker(baseAddr, cloudAddr)
cloudPeer := pick("/v1/chat/completions")
if cloudPeer != cloud.NodeID() {
t.Errorf("cloud route peerID = %q, want %q", cloudPeer, cloud.NodeID())
}
basePeer := pick("/v1/base/records")
if basePeer != base.NodeID() {
t.Errorf("base route peerID = %q, want %q", basePeer, base.NodeID())
}
// Idempotent + cached: resolving again yields the same id and adds no
// new connection (ConnectDirectID dedups), so a healthy backend is
// dialed exactly once.
if again := pick("/v1/chat/completions"); again != cloudPeer {
t.Errorf("second cloud resolve = %q, want cached %q", again, cloudPeer)
}
peers := gw.Peers()
if len(peers) != 2 {
t.Errorf("gateway holds %d peer conns, want 2 (one per backend, no dupes): %v", len(peers), peers)
}
}
// TestLazyPicker_RecoversWhenBackendComesUp proves the retry property: a
// path that resolved to "" while its backend was down resolves correctly
// once the backend exists, with no restart. (Boot through outage, then heal.)
func TestLazyPicker_RecoversWhenBackendComesUp(t *testing.T) {
gw := startedNode(t, "gateway")
// Reserve a port, keep it CLOSED so the first dial is refused, then
// bring a node up on that exact port and confirm the next resolve wins.
port := reservePort(t)
addr := "127.0.0.1:" + strconv.Itoa(port)
r := newPeerResolver(gw, luxlog.NewNoOpLogger())
pick := r.picker(addr, addr)
if got := pick("/v1/chat/completions"); got != "" {
t.Fatalf("resolve before backend up = %q, want \"\"", got)
}
// Backend appears on the reserved port.
be := zaplib.NewNode(zaplib.NodeConfig{NodeID: "late-be", Port: port, NoDiscovery: true})
if err := be.Start(); err != nil {
t.Fatalf("late backend start on :%d: %v", port, err)
}
t.Cleanup(be.Stop)
// Retry resolves now (lazy picker re-dials on each miss).
deadline := time.Now().Add(3 * time.Second)
for {
if got := pick("/v1/chat/completions"); got == be.NodeID() {
break
}
if time.Now().After(deadline) {
t.Fatalf("resolve after backend up never returned %q", be.NodeID())
}
time.Sleep(25 * time.Millisecond)
}
}
// TestPeerResolver_NilPickFallsBackToStatic guards the RegisterRelay
// contract that a nil Pick still works via the static pickPeer path (the
// pre-existing e2e test relies on this).
func TestPeerResolver_NilPickFallsBackToStatic(t *testing.T) {
node := startedNode(t, "gateway")
err := RegisterRelay(RelayDeps{
Logger: luxlog.NewNoOpLogger(),
Node: node,
Pick: nil, // exercise the static fallback
BasePeerID: "base-id",
CloudPeerID: "cloud-id",
Auth: AuthConfig{Enabled: false},
})
if err != nil {
t.Fatalf("RegisterRelay with nil Pick (static fallback) errored: %v", err)
}
}
// reservePort grabs a free TCP port and releases it, returning the number.
// Same approach as freePortInt in gate_test.go; duplicated-free is fine on
// loopback for tests.
func reservePort(t *testing.T) int {
t.Helper()
return freePortInt(t)
}