From 8f088888285ae05e6d49d8d957c55c832dd5b5f1 Mon Sep 17 00:00:00 2001 From: Santiago Botto Date: Tue, 12 May 2026 14:47:11 -0300 Subject: [PATCH 1/2] fix: Improve how websockets deal with timeouts and closed connections --- internal/server/server.go | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index d2a29f5..3685c6d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1184,11 +1184,16 @@ func (s *Server) defaultForwardRequestWithBodyFunc(w http.ResponseWriter, ctx co return err } -// proxyWebSocketCopy copies messages from src to dst +// proxyWebSocketCopy copies messages from src to dst, forwarding close frames +// to the destination so both peers receive a proper WebSocket close handshake. func proxyWebSocketCopy(src, dst *websocket.Conn) error { for { msgType, msg, err := src.ReadMessage() if err != nil { + if closeErr, ok := err.(*websocket.CloseError); ok { + _ = dst.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(closeErr.Code, closeErr.Text)) + } return err } if err := dst.WriteMessage(msgType, msg); err != nil { @@ -1294,8 +1299,6 @@ func (s *Server) defaultProxyWebSocket(w http.ResponseWriter, r *http.Request, b backendConn.Close() return err } - defer clientConn.Close() - // Proxy messages in both directions errc := make(chan error, 2) go func() { @@ -1306,13 +1309,21 @@ func (s *Server) defaultProxyWebSocket(w http.ResponseWriter, r *http.Request, b err := proxyWebSocketCopy(backendConn, clientConn) errc <- err }() - // Wait for one direction to fail/close + // Wait for one direction to fail/close, then immediately close both + // connections so the other goroutine unblocks and finishes cleanly. err = <-errc + clientConn.Close() + backendConn.Close() + <-errc // wait for the second goroutine to finish // Mark endpoint as unhealthy for WS if error is not a normal closure if err != nil { if isExpectedWSClose(err) { - log.Debug().Err(err).Str("endpoint", helpers.RedactAPIKey(backendURL)).Msg("WebSocket connection closed normally") + if closeErr, ok := err.(*websocket.CloseError); ok && closeErr.Code == websocket.CloseAbnormalClosure { + log.Debug().Err(err).Str("endpoint", helpers.RedactAPIKey(backendURL)).Msg("WebSocket connection closed abnormally (1006), not counting as failure") + } else { + log.Debug().Err(err).Str("endpoint", helpers.RedactAPIKey(backendURL)).Msg("WebSocket connection closed normally") + } return nil } if chain, endpointID, found := s.findChainAndEndpointByURL(backendURL); found { From abcb20b339f7f44d96a150ffb6464aff773e351c Mon Sep 17 00:00:00 2001 From: Santiago Botto Date: Tue, 12 May 2026 16:09:17 -0300 Subject: [PATCH 2/2] Fix WebSocket close-code protocol violation --- internal/server/server.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/server/server.go b/internal/server/server.go index 3685c6d..9262a6c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1191,8 +1191,16 @@ func proxyWebSocketCopy(src, dst *websocket.Conn) error { msgType, msg, err := src.ReadMessage() if err != nil { if closeErr, ok := err.(*websocket.CloseError); ok { + code := closeErr.Code + // RFC 6455: 1005, 1006, 1015 must not be sent on the wire. + switch code { + case websocket.CloseNoStatusReceived, + websocket.CloseAbnormalClosure, + websocket.CloseTLSHandshake: + code = websocket.CloseGoingAway + } _ = dst.WriteMessage(websocket.CloseMessage, - websocket.FormatCloseMessage(closeErr.Code, closeErr.Text)) + websocket.FormatCloseMessage(code, closeErr.Text)) } return err }