From b24336170c96663cf35e68b805b4c11152102a75 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 27 Oct 2026 18:45:16 +0000 Subject: [PATCH 1/2] feat: add ErrBadStatus for non-101 Dial responses Dial returned a non-nil response both when the server rejected the handshake with a non-101 status and when a 101 response failed validation, so callers checking resp != nil treated protocol violations as HTTP errors. Dial now wraps ErrBadStatus only for a non-101 status, and documents that the response and its first 1024 body bytes are available in that case. --- dial.go | 8 +++--- dial_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++- errors.go | 8 ++++++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/dial.go b/dial.go index f5e4544b..8da633dc 100644 --- a/dial.go +++ b/dial.go @@ -109,8 +109,10 @@ func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context // The response is the WebSocket handshake response from the server. // You never need to close resp.Body yourself. // -// If an error occurs, the returned response may be non nil. -// However, you can only read the first 1024 bytes of the body. +// If an error occurs, the returned response may be non nil. If the server +// answers with a status other than 101 Switching Protocols, err wraps +// ErrBadStatus, the response is non nil, and resp.Body holds up to the +// first 1024 bytes of the response body. // // This function requires at least Go 1.12 as it uses a new feature // in net/http to perform WebSocket handshakes. @@ -241,7 +243,7 @@ func secWebSocketKey(rr io.Reader) (string, error) { func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSocketKey string, resp *http.Response) (*compressionOptions, error) { if resp.StatusCode != http.StatusSwitchingProtocols { - return nil, fmt.Errorf("expected handshake response status code %v but got %v", http.StatusSwitchingProtocols, resp.StatusCode) + return nil, fmt.Errorf("%w: expected handshake response status code %v but got %v", ErrBadStatus, http.StatusSwitchingProtocols, resp.StatusCode) } if !headerContainsTokenIgnoreCase(resp.Header, "Connection", "Upgrade") { diff --git a/dial_test.go b/dial_test.go index 492ac6b3..4c612c10 100644 --- a/dial_test.go +++ b/dial_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "crypto/rand" + "errors" "io" "maps" "net/http" @@ -94,7 +95,7 @@ func TestBadDials(t *testing.T) { }, nil }), }) - assert.Contains(t, err, "failed to WebSocket dial: expected handshake response status code 101 but got 0") + assert.Contains(t, err, "failed to WebSocket dial: websocket: bad status: expected handshake response status code 101 but got 0") }) t.Run("badBody", func(t *testing.T) { @@ -123,6 +124,79 @@ func TestBadDials(t *testing.T) { }) } +func TestDialErrBadStatus(t *testing.T) { + t.Parallel() + + t.Run("nonSwitchingStatus", func(t *testing.T) { + t.Parallel() + + body := strings.Repeat("a", 1024) + "b" + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, body) + })) + defer s.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + _, resp, err := websocket.Dial(ctx, s.URL, nil) + assert.ErrorIs(t, websocket.ErrBadStatus, err) + assert.Contains(t, err, "expected handshake response status code 101 but got 403") + if resp == nil { + t.Fatal("expected non-nil response") + } + assert.Equal(t, "status code", http.StatusForbidden, resp.StatusCode) + b, err := io.ReadAll(resp.Body) + assert.Success(t, err) + assert.Equal(t, "body", body[:1024], string(b)) + }) + + t.Run("invalidAccept", func(t *testing.T) { + t.Parallel() + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, brw, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer conn.Close() + brw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: invalid\r\n\r\n") + brw.Flush() + })) + defer s.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + _, resp, err := websocket.Dial(ctx, s.URL, nil) + assert.Contains(t, err, "invalid Sec-WebSocket-Accept") + assert.Equal(t, "errors.Is(err, ErrBadStatus)", false, errors.Is(err, websocket.ErrBadStatus)) + if resp == nil { + t.Fatal("expected non-nil response") + } + assert.Equal(t, "status code", http.StatusSwitchingProtocols, resp.StatusCode) + }) + + t.Run("networkError", func(t *testing.T) { + t.Parallel() + + s := httptest.NewServer(http.NotFoundHandler()) + u := s.URL + s.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + _, resp, err := websocket.Dial(ctx, u, nil) + assert.Error(t, err) + assert.Equal(t, "errors.Is(err, ErrBadStatus)", false, errors.Is(err, websocket.ErrBadStatus)) + if resp != nil { + t.Fatalf("expected nil response, got status %d", resp.StatusCode) + } + }) +} + func Test_verifyHostOverride(t *testing.T) { testCases := []struct { name string diff --git a/errors.go b/errors.go index bf4fc2b0..6b7f94b8 100644 --- a/errors.go +++ b/errors.go @@ -4,5 +4,13 @@ import ( "errors" ) +// ErrBadStatus is wrapped by the error from Dial when the server answers +// the opening handshake with an HTTP status other than 101 Switching +// Protocols. See Dial for the response returned with it. +// +// On js/wasm, Dial never returns an error wrapping ErrBadStatus because +// the browser does not expose the handshake response. +var ErrBadStatus = errors.New("websocket: bad status") + // ErrMessageTooBig is returned when a message exceeds the read limit. var ErrMessageTooBig = errors.New("websocket: message too big") From 6a36d7aa584c7dbb5b06b59b080ad5b06279c99d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 28 Oct 2026 10:38:37 +0000 Subject: [PATCH 2/2] fix: keep the Dial error text for non-101 status Wrapping ErrBadStatus with %w inserted "websocket: bad status: " into the Dial error string, and public projects match on the existing text. An unexported error type now returns the previous text and unwraps to ErrBadStatus, so errors.Is still holds. --- dial.go | 16 +++++++++++++++- dial_test.go | 5 +++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/dial.go b/dial.go index 8da633dc..60016883 100644 --- a/dial.go +++ b/dial.go @@ -243,7 +243,7 @@ func secWebSocketKey(rr io.Reader) (string, error) { func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSocketKey string, resp *http.Response) (*compressionOptions, error) { if resp.StatusCode != http.StatusSwitchingProtocols { - return nil, fmt.Errorf("%w: expected handshake response status code %v but got %v", ErrBadStatus, http.StatusSwitchingProtocols, resp.StatusCode) + return nil, badStatusError{statusCode: resp.StatusCode} } if !headerContainsTokenIgnoreCase(resp.Header, "Connection", "Upgrade") { @@ -269,6 +269,20 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo return verifyServerExtensions(copts, resp.Header) } +// badStatusError wraps ErrBadStatus without adding its text, because +// callers match on the Dial error string from before ErrBadStatus existed. +type badStatusError struct { + statusCode int +} + +func (e badStatusError) Error() string { + return fmt.Sprintf("expected handshake response status code %v but got %v", http.StatusSwitchingProtocols, e.statusCode) +} + +func (badStatusError) Unwrap() error { + return ErrBadStatus +} + func verifySubprotocol(subprotos []string, resp *http.Response) error { proto := resp.Header.Get("Sec-WebSocket-Protocol") if proto == "" { diff --git a/dial_test.go b/dial_test.go index 4c612c10..f5de6eb3 100644 --- a/dial_test.go +++ b/dial_test.go @@ -95,7 +95,7 @@ func TestBadDials(t *testing.T) { }, nil }), }) - assert.Contains(t, err, "failed to WebSocket dial: websocket: bad status: expected handshake response status code 101 but got 0") + assert.Contains(t, err, "failed to WebSocket dial: expected handshake response status code 101 but got 0") }) t.Run("badBody", func(t *testing.T) { @@ -141,8 +141,9 @@ func TestDialErrBadStatus(t *testing.T) { defer cancel() _, resp, err := websocket.Dial(ctx, s.URL, nil) + assert.Error(t, err) + assert.Equal(t, "error", "failed to WebSocket dial: expected handshake response status code 101 but got 403", err.Error()) assert.ErrorIs(t, websocket.ErrBadStatus, err) - assert.Contains(t, err, "expected handshake response status code 101 but got 403") if resp == nil { t.Fatal("expected non-nil response") }