Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions services/nvpair-proxy/failover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ func TestHandleHTTP_HappyPathSingleNode(t *testing.T) {
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Access-Control-Allow-Origin = %q, want no CORS header on success", got)
}
if got := rec.Header().Get(servedByHeader); got != "good" {
t.Errorf("%s = %q, want committed node %q", servedByHeader, got, "good")
}
})
}

Expand Down Expand Up @@ -282,6 +285,9 @@ func TestHandleHTTP_RejectionHasNoCORS(t *testing.T) {
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Access-Control-Allow-Origin = %q, want no CORS header on rejection", got)
}
if got := rec.Header().Get(servedByHeader); got != "" {
t.Errorf("%s = %q, want absent when no upstream committed", servedByHeader, got)
}
})
}

Expand Down Expand Up @@ -322,6 +328,9 @@ func TestHandleHTTP_FailoverOn503(t *testing.T) {
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Access-Control-Allow-Origin = %q, want no CORS header on proxied success", got)
}
if got := rec.Header().Get(servedByHeader); got != "good" {
t.Errorf("%s = %q, want successful failover node %q", servedByHeader, got, "good")
}
})
}

Expand Down Expand Up @@ -354,6 +363,33 @@ func TestHandleHTTP_AllNodesDownReturnsError(t *testing.T) {
})
}

func TestHandleHTTP_ServedByHeaderInferenceOnly(t *testing.T) {
forEachEngine(t, func(t *testing.T, tc engineCase) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(servedByHeader, "spoofed")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"ok":true}`)
}))
defer upstream.Close()

disc := NewDiscovery()
disc.AddManual(nodeForModel(t, "served", upstream.URL, tc.advertisedModel))
p := testProxy(tc.profile, disc, tc.profile.FacadePort)

inference := httptest.NewRecorder()
p.soleFacade().handleHTTP(inference, tc.inferenceRequest())
if got := inference.Header().Get(servedByHeader); got != "served" {
t.Fatalf("%s = %q, want %q", servedByHeader, got, "served")
}

control := httptest.NewRecorder()
p.soleFacade().handleHTTP(control, httptest.NewRequest(http.MethodGet, tc.nonInferencePath, nil))
if got := control.Header().Get(servedByHeader); got != "" {
t.Fatalf("%s = %q on control request, want absent", servedByHeader, got)
}
})
}

// TestHandleHTTP_404FailoverInferenceOnly: a 404 (model-not-found) on an
// inference call fails over to the next advertised owner, but a 404 on a
// non-inference path is returned as-is.
Expand Down
11 changes: 11 additions & 0 deletions services/nvpair-proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ type ErrorParams struct {
// sits in the implementation-defined -32000..-32099 server range.
const codeFacadeBindFailed = -32010

// servedByHeader exposes the stable host UUID of the candidate that actually
// committed the proxied response. Set only after failover has settled so the
// client sees the same execution identity recorded in workload scheduledOn.
const servedByHeader = "X-PAIR-Served-By"

type NodesResult struct {
Nodes []Node `json:"nodes"`
}
Expand Down Expand Up @@ -1764,6 +1769,12 @@ func (f *facade) handleHTTP(w http.ResponseWriter, r *http.Request) {
ttfbMs = time.Since(start).Milliseconds()
servedNodeID = cand.id
servedTarget = cand.url.Host
// The public serving-node contract is for inference responses.
// Keep control endpoints unchanged, and override any upstream spoofed
// value only when PAIR owns this response header.
if isInf {
resp.Header.Set(servedByHeader, cand.id)
}
proxyErr = "" // clear any error recorded from a failed-over candidate
// Arm the liveness report only now. statusCapture also carries
// the proxy's OWN error bodies — ReverseProxy's ErrorHandler
Expand Down
Loading