From 38a8d7c031755038d667552c3b67b51bbbd42ced Mon Sep 17 00:00:00 2001 From: logonoff Date: Fri, 17 Jul 2026 15:34:53 -0400 Subject: [PATCH] OCPBUGS-99052: recover from reverse proxy panic on browser disconnect The API discovery handler uses httptest.NewRecorder to make in-process requests through the k8s reverse proxy. When a browser disconnects mid-request (e.g. page refresh), httputil.ReverseProxy panics with http.ErrAbortHandler during response body copy. Unlike a real HTTP server, the recorder has no server to catch this panic, crashing the goroutine. Add a defer/recover in k8sViaProxy that catches http.ErrAbortHandler and converts it to an error. Unexpected panics are re-raised. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/server/api_discovery.go | 15 +++++++++++-- pkg/server/api_discovery_test.go | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/pkg/server/api_discovery.go b/pkg/server/api_discovery.go index a7bdf98894c..a7ee8f1e2a3 100644 --- a/pkg/server/api_discovery.go +++ b/pkg/server/api_discovery.go @@ -102,7 +102,7 @@ func apiDiscoveryHandler(k8sProxy *proxy.Proxy) http.HandlerFunc { } // k8sViaProxy makes an in-process GET request through the k8s proxy. -func k8sViaProxy(k8sProxy *proxy.Proxy, path string, originalReq *http.Request) ([]byte, error) { +func k8sViaProxy(handler http.Handler, path string, originalReq *http.Request) (body []byte, err error) { req, err := http.NewRequestWithContext(originalReq.Context(), "GET", path, nil) if err != nil { return nil, fmt.Errorf("failed to create request for %s", path) @@ -111,7 +111,18 @@ func k8sViaProxy(k8sProxy *proxy.Proxy, path string, originalReq *http.Request) req.Header = originalReq.Header.Clone() rec := httptest.NewRecorder() - k8sProxy.ServeHTTP(rec, req) + + defer func() { + if p := recover(); p != nil { + if p == http.ErrAbortHandler { + err = fmt.Errorf("request aborted for %s", path) + } else { + panic(p) + } + } + }() + + handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { return nil, fmt.Errorf("unexpected status %d for %s", rec.Code, path) diff --git a/pkg/server/api_discovery_test.go b/pkg/server/api_discovery_test.go index 15b76eb22bb..3189a004250 100644 --- a/pkg/server/api_discovery_test.go +++ b/pkg/server/api_discovery_test.go @@ -313,6 +313,44 @@ func TestApiDiscoveryHandler_AllResourceListsFail(t *testing.T) { } } +func TestK8sViaProxy_RecoversAbortPanic(t *testing.T) { + t.Run("ErrAbortHandler is recovered", func(t *testing.T) { + panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic(http.ErrAbortHandler) + }) + + req := httptest.NewRequest(http.MethodGet, "/apis/test/v1", nil) + _, err := k8sViaProxy(panicHandler, "/apis/test/v1", req) + + if err == nil { + t.Fatal("expected error from aborted request, got nil") + } + if !strings.Contains(err.Error(), "request aborted") { + t.Errorf("expected 'request aborted' error, got: %v", err) + } + }) + + t.Run("unexpected panic is not swallowed", func(t *testing.T) { + sentinel := "unexpected failure" + panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic(sentinel) + }) + + defer func() { + p := recover() + if p == nil { + t.Fatal("expected panic to propagate, but it was swallowed") + } + if p != sentinel { + t.Fatalf("expected panic value %q, got %v", sentinel, p) + } + }() + + req := httptest.NewRequest(http.MethodGet, "/apis/test/v1", nil) + k8sViaProxy(panicHandler, "/apis/test/v1", req) + }) +} + func TestApiDiscoveryHandler_PartialFailure(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json")