From 9865ec90cce4dc2ac475a9478f21a5b88116c377 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 27 Oct 2026 18:45:16 +0000 Subject: [PATCH 01/17] 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 | 5 +++- dial_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++- errors.go | 8 ++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/dial.go b/dial.go index 371a7d8e..6bd3d189 100644 --- a/dial.go +++ b/dial.go @@ -114,6 +114,9 @@ func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context // connection. For any other status, resp.Body holds at most the first // 1024 bytes of the body. // +// If the server answers with a status other than 101 Switching Protocols, +// err wraps ErrBadStatus and the response is non nil. +// // This function requires at least Go 1.12 as it uses a new feature // in net/http to perform WebSocket handshakes. // See docs on the HTTPClient option and https://github.com/golang/go/issues/26937#issuecomment-415855861 @@ -252,7 +255,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 2d1c94d4..6ab511d3 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 855da906f48f2b6735a1b27f70ace561f1bbbcf5 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 28 Oct 2026 10:38:37 +0000 Subject: [PATCH 02/17] 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 6bd3d189..f4c55b35 100644 --- a/dial.go +++ b/dial.go @@ -255,7 +255,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") { @@ -281,6 +281,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 6ab511d3..81b1d031 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") } From 0f29825e63e27edd47af8f9311167ba29df9b023 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 5 Nov 2026 12:00:26 +0000 Subject: [PATCH 03/17] docs(dial.go): leave the ErrBadStatus condition to its doc The Dial doc repeated the condition that the ErrBadStatus doc states, so a change to the condition had to edit both. --- dial.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dial.go b/dial.go index f4c55b35..21c1144c 100644 --- a/dial.go +++ b/dial.go @@ -113,9 +113,7 @@ func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context // response, resp.Body is empty, because its bytes belong to the upgraded // connection. For any other status, resp.Body holds at most the first // 1024 bytes of the body. -// -// If the server answers with a status other than 101 Switching Protocols, -// err wraps ErrBadStatus and the response is non nil. +// If err wraps ErrBadStatus, resp is non nil. // // This function requires at least Go 1.12 as it uses a new feature // in net/http to perform WebSocket handshakes. From 6f4c58935a972fb10acd18152dd5d7fba6bbfe72 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 19:39:33 +0000 Subject: [PATCH 04/17] docs(errors.go): limit ErrBadStatus to the final HTTPClient response A 302 refused by HTTPClient.CheckRedirect makes HTTPClient.Do return an error, so Dial returns a request error with a nil response and no ErrBadStatus. The doc said any non-101 answer wraps ErrBadStatus, which is false for that redirect. --- errors.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/errors.go b/errors.go index 6b7f94b8..84e3689d 100644 --- a/errors.go +++ b/errors.go @@ -4,9 +4,12 @@ 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. +// ErrBadStatus is wrapped by the error from Dial when the final response +// that DialOptions.HTTPClient returns for the opening handshake has a +// status other than 101 Switching Protocols. See Dial for the response +// returned with it. A redirect that HTTPClient.CheckRedirect refuses is +// returned as a request error with a nil response, so it does not wrap +// ErrBadStatus. // // On js/wasm, Dial never returns an error wrapping ErrBadStatus because // the browser does not expose the handshake response. From 658cd7396bd523e440d162e91442a7c6a4c47208 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 19:39:51 +0000 Subject: [PATCH 05/17] docs(dial.go): describe what badStatusError does The comment claimed that callers match on the Dial error string, which no linked caller supports, and described the history of the change. It now states only how Error and Unwrap behave. --- dial.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dial.go b/dial.go index 21c1144c..27072a11 100644 --- a/dial.go +++ b/dial.go @@ -279,8 +279,8 @@ 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. +// badStatusError lets errors.Is match ErrBadStatus through Unwrap while +// Error keeps the Dial error text unchanged, without the ErrBadStatus text. type badStatusError struct { statusCode int } From 65d0254fe46f070aa2a132ee81dda58d724587bd Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 19:40:43 +0000 Subject: [PATCH 06/17] test(dial_test.go): check ErrBadStatus for every handshake failure invalidAccept checked one of the six 101 validation failures, so making another one wrap ErrBadStatus left the tests passing. The assertion in Test_verifyServerHandshake covers every row and replaces invalidAccept. --- dial_test.go | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/dial_test.go b/dial_test.go index 81b1d031..63e6e82d 100644 --- a/dial_test.go +++ b/dial_test.go @@ -153,32 +153,6 @@ func TestDialErrBadStatus(t *testing.T) { 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() @@ -367,6 +341,7 @@ func Test_verifyServerHandshake(t *testing.T) { } else { assert.Error(t, err) } + assert.Equal(t, "errors.Is(err, ErrBadStatus)", tc.name == "badStatus", errors.Is(err, websocket.ErrBadStatus)) }) } } From b21e54b54dd117065eecf72f9727f96fc8fa9515 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 19:41:42 +0000 Subject: [PATCH 07/17] test(dial_test.go): use a failing round tripper for networkError Dialing the address of a closed httptest.Server can reach a server that a parallel test started on the same port. A round tripper that returns an error tests the same nil-response path without a port. --- dial_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dial_test.go b/dial_test.go index 63e6e82d..66a9de4b 100644 --- a/dial_test.go +++ b/dial_test.go @@ -156,14 +156,14 @@ func TestDialErrBadStatus(t *testing.T) { 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) + _, resp, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{ + HTTPClient: mockHTTPClient(func(*http.Request) (*http.Response, error) { + return nil, errors.New("connection refused") + }), + }) assert.Error(t, err) assert.Equal(t, "errors.Is(err, ErrBadStatus)", false, errors.Is(err, websocket.ErrBadStatus)) if resp != nil { From 356d60c02208eb634d166c87455ee35e7d20e074 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 19:56:48 +0000 Subject: [PATCH 08/17] docs(dial.go): restore why badStatusError keeps the Dial error text Public callers match on the Dial error text, for example with strings.EqualFold on the full message, so the reason the type exists belongs in its comment. --- dial.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dial.go b/dial.go index 27072a11..6b439fe2 100644 --- a/dial.go +++ b/dial.go @@ -279,8 +279,8 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo return verifyServerExtensions(copts, resp.Header) } -// badStatusError lets errors.Is match ErrBadStatus through Unwrap while -// Error keeps the Dial error text unchanged, without the ErrBadStatus text. +// badStatusError keeps the Dial error text unchanged, because callers match +// on that text, while Unwrap exposes ErrBadStatus to errors.Is. type badStatusError struct { statusCode int } From 1d618edecc06e64af6b655ca1c0b38d23d2a3d09 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:01:37 +0000 Subject: [PATCH 09/17] docs(errors.go): fix the ErrBadStatus redirect rule The doc said a redirect that CheckRedirect refuses never wraps ErrBadStatus, but returning http.ErrUseLastResponse stops the redirect and returns the 3xx response, which does wrap it. --- errors.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/errors.go b/errors.go index 84e3689d..6ddd5803 100644 --- a/errors.go +++ b/errors.go @@ -7,9 +7,9 @@ import ( // ErrBadStatus is wrapped by the error from Dial when the final response // that DialOptions.HTTPClient returns for the opening handshake has a // status other than 101 Switching Protocols. See Dial for the response -// returned with it. A redirect that HTTPClient.CheckRedirect refuses is -// returned as a request error with a nil response, so it does not wrap -// ErrBadStatus. +// returned with it. If HTTPClient.CheckRedirect returns an error other than +// http.ErrUseLastResponse, the error from Dial does not wrap ErrBadStatus +// and the response is nil. // // On js/wasm, Dial never returns an error wrapping ErrBadStatus because // the browser does not expose the handshake response. From af17077b73e5563956bc264615cad2d0964704c3 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:01:37 +0000 Subject: [PATCH 10/17] test(dial_test.go): check ErrBadStatus on redirects The ErrBadStatus doc says how a CheckRedirect error and http.ErrUseLastResponse are reported, and a non-writable body after a 101 must not wrap it, but no test checked either. --- dial_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/dial_test.go b/dial_test.go index 66a9de4b..80aee16c 100644 --- a/dial_test.go +++ b/dial_test.go @@ -121,6 +121,7 @@ func TestBadDials(t *testing.T) { HTTPClient: mockHTTPClient(rt), }) assert.Contains(t, err, "response body is not a io.ReadWriteCloser") + assert.Equal(t, "errors.Is(err, ErrBadStatus)", false, errors.Is(err, websocket.ErrBadStatus)) }) } @@ -170,6 +171,52 @@ func TestDialErrBadStatus(t *testing.T) { t.Fatalf("expected nil response, got status %d", resp.StatusCode) } }) + + redirect := func(checkRedirect error) *http.Client { + hc := mockHTTPClient(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{"Location": {"wss://example.com/login"}}, + }, nil + }) + hc.CheckRedirect = func(*http.Request, []*http.Request) error { + return checkRedirect + } + return hc + } + + t.Run("redirectRefused", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + errRefused := errors.New("redirect refused") + _, resp, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{ + HTTPClient: redirect(errRefused), + }) + assert.ErrorIs(t, errRefused, 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) + } + }) + + t.Run("redirectUseLastResponse", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + _, resp, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{ + HTTPClient: redirect(http.ErrUseLastResponse), + }) + assert.ErrorIs(t, websocket.ErrBadStatus, err) + if resp == nil { + t.Fatal("expected non-nil response") + } + assert.Equal(t, "status code", http.StatusFound, resp.StatusCode) + }) } func Test_verifyHostOverride(t *testing.T) { From 9421678c30c1dffe2ab2a5b7944dcd4f90e97b50 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:01:53 +0000 Subject: [PATCH 11/17] docs(dial.go): say why badStatusError uses Unwrap An Is method would also satisfy errors.Is and would keep the innermost error's text. Unwrap is kept so code that walks the error chain finds ErrBadStatus too. --- dial.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dial.go b/dial.go index 6b439fe2..94d44a81 100644 --- a/dial.go +++ b/dial.go @@ -280,7 +280,8 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo } // badStatusError keeps the Dial error text unchanged, because callers match -// on that text, while Unwrap exposes ErrBadStatus to errors.Is. +// on that text. Unwrap, unlike an Is method, puts ErrBadStatus in the error +// chain, so callers can find it with errors.Unwrap as well as errors.Is. type badStatusError struct { statusCode int } From 29e95828da9dfbe1a4954adde1dee6815bcc3f13 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:05:38 +0000 Subject: [PATCH 12/17] docs(dial.go): say only chain walks find ErrBadStatus A single errors.Unwrap on the Dial error returns badStatusError, so the comment's errors.Unwrap claim held only for code that walks the chain. --- dial.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dial.go b/dial.go index 94d44a81..08645818 100644 --- a/dial.go +++ b/dial.go @@ -281,7 +281,7 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo // badStatusError keeps the Dial error text unchanged, because callers match // on that text. Unwrap, unlike an Is method, puts ErrBadStatus in the error -// chain, so callers can find it with errors.Unwrap as well as errors.Is. +// chain, so code that walks the chain finds it, not only errors.Is. type badStatusError struct { statusCode int } From 3ded90d96d032920af799ae8e6566ff05179183a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:21:54 +0000 Subject: [PATCH 13/17] docs: move Dial behavior from the ErrBadStatus doc to Dial The ErrBadStatus doc described which HTTPClient response counts, how CheckRedirect errors are returned and what happens on js/wasm, which is Dial's behavior. ErrBadStatus now says only what it means, and both Dial docs state their own rules. --- dial.go | 7 ++++++- errors.go | 11 ++--------- ws_js.go | 3 ++- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/dial.go b/dial.go index 08645818..55fc0450 100644 --- a/dial.go +++ b/dial.go @@ -113,7 +113,12 @@ func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context // response, resp.Body is empty, because its bytes belong to the upgraded // connection. For any other status, resp.Body holds at most the first // 1024 bytes of the body. -// If err wraps ErrBadStatus, resp is non nil. +// +// The error wraps ErrBadStatus when the final response that +// DialOptions.HTTPClient returns has a status other than 101 Switching +// Protocols, and resp is then non nil. If HTTPClient.CheckRedirect returns +// an error other than http.ErrUseLastResponse, the error does not wrap +// ErrBadStatus and resp is nil. // // This function requires at least Go 1.12 as it uses a new feature // in net/http to perform WebSocket handshakes. diff --git a/errors.go b/errors.go index 6ddd5803..0e280356 100644 --- a/errors.go +++ b/errors.go @@ -4,15 +4,8 @@ import ( "errors" ) -// ErrBadStatus is wrapped by the error from Dial when the final response -// that DialOptions.HTTPClient returns for the opening handshake has a -// status other than 101 Switching Protocols. See Dial for the response -// returned with it. If HTTPClient.CheckRedirect returns an error other than -// http.ErrUseLastResponse, the error from Dial does not wrap ErrBadStatus -// and the response is nil. -// -// On js/wasm, Dial never returns an error wrapping ErrBadStatus because -// the browser does not expose the handshake response. +// ErrBadStatus is wrapped by the error from Dial when the server answers +// the opening handshake with a status other than 101 Switching Protocols. var ErrBadStatus = errors.New("websocket: bad status") // ErrMessageTooBig is returned when a message exceeds the read limit. diff --git a/ws_js.go b/ws_js.go index 026b75fc..fae4dc7b 100644 --- a/ws_js.go +++ b/ws_js.go @@ -286,7 +286,8 @@ type DialOptions struct { // Dial creates a new WebSocket connection to the given url with the given options. // The passed context bounds the maximum time spent waiting for the connection to open. // The returned *http.Response is always nil or a mock. It's only in the signature -// to match the core API. +// to match the core API. The error never wraps ErrBadStatus, because the +// browser does not expose the handshake response. func Dial(ctx context.Context, url string, opts *DialOptions) (*Conn, *http.Response, error) { c, resp, err := dial(ctx, url, opts) if err != nil { From 02a45765c49e764e3b8cb6f9facc3d917b926af6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:28:58 +0000 Subject: [PATCH 14/17] fix: keep the innermost Dial error text with ErrBadStatus Unwrap made ErrBadStatus the innermost error, so code that unwraps a Dial error to its innermost error got "websocket: bad status" instead of the status text. An Is method gives the same errors.Is result without changing the chain. --- dial.go | 12 ++++++------ errors.go | 6 ++++-- ws_js.go | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/dial.go b/dial.go index 55fc0450..92820ed6 100644 --- a/dial.go +++ b/dial.go @@ -114,10 +114,10 @@ func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context // connection. For any other status, resp.Body holds at most the first // 1024 bytes of the body. // -// The error wraps ErrBadStatus when the final response that +// The error matches ErrBadStatus when the final response that // DialOptions.HTTPClient returns has a status other than 101 Switching // Protocols, and resp is then non nil. If HTTPClient.CheckRedirect returns -// an error other than http.ErrUseLastResponse, the error does not wrap +// an error other than http.ErrUseLastResponse, the error does not match // ErrBadStatus and resp is nil. // // This function requires at least Go 1.12 as it uses a new feature @@ -285,8 +285,8 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo } // badStatusError keeps the Dial error text unchanged, because callers match -// on that text. Unwrap, unlike an Is method, puts ErrBadStatus in the error -// chain, so code that walks the chain finds it, not only errors.Is. +// on that text. It matches ErrBadStatus through Is instead of Unwrap, so code +// that unwraps to the innermost error still gets that text. type badStatusError struct { statusCode int } @@ -295,8 +295,8 @@ 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 (badStatusError) Is(target error) bool { + return target == ErrBadStatus } func verifySubprotocol(subprotos []string, resp *http.Response) error { diff --git a/errors.go b/errors.go index 0e280356..e6cad3bf 100644 --- a/errors.go +++ b/errors.go @@ -4,8 +4,10 @@ import ( "errors" ) -// ErrBadStatus is wrapped by the error from Dial when the server answers -// the opening handshake with a status other than 101 Switching Protocols. +// ErrBadStatus means that the server answered the opening handshake from +// Dial with a status other than 101 Switching Protocols. Test for it with +// errors.Is. For backward compatibility, the error from Dial does not +// unwrap to it. var ErrBadStatus = errors.New("websocket: bad status") // ErrMessageTooBig is returned when a message exceeds the read limit. diff --git a/ws_js.go b/ws_js.go index fae4dc7b..cadcb15e 100644 --- a/ws_js.go +++ b/ws_js.go @@ -286,7 +286,7 @@ type DialOptions struct { // Dial creates a new WebSocket connection to the given url with the given options. // The passed context bounds the maximum time spent waiting for the connection to open. // The returned *http.Response is always nil or a mock. It's only in the signature -// to match the core API. The error never wraps ErrBadStatus, because the +// to match the core API. The error never matches ErrBadStatus, because the // browser does not expose the handshake response. func Dial(ctx context.Context, url string, opts *DialOptions) (*Conn, *http.Response, error) { c, resp, err := dial(ctx, url, opts) From 8fb107007ad48bfe9e0229265794e35efe71340a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:28:58 +0000 Subject: [PATCH 15/17] test(dial_test.go): check the innermost Dial error text Unwrap and Is give the same errors.Is result, so only the innermost error shows whether a change brought back the old chain. --- dial_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dial_test.go b/dial_test.go index 80aee16c..914199fd 100644 --- a/dial_test.go +++ b/dial_test.go @@ -145,6 +145,12 @@ func TestDialErrBadStatus(t *testing.T) { 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) + // Code that unwraps to the innermost error must still get the status text. + inner := err + for e := errors.Unwrap(inner); e != nil; e = errors.Unwrap(inner) { + inner = e + } + assert.Equal(t, "innermost error", "expected handshake response status code 101 but got 403", inner.Error()) if resp == nil { t.Fatal("expected non-nil response") } From 3afab7fd0aaae0e75d5f8355a5852b3a45ade989 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:39:32 +0000 Subject: [PATCH 16/17] test(dial_test.go): name the redirect helper redirectClient The helper returns an *http.Client, but HTTPClient: redirect(...) read as performing a redirect. --- dial_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dial_test.go b/dial_test.go index 914199fd..11b88ab8 100644 --- a/dial_test.go +++ b/dial_test.go @@ -178,7 +178,7 @@ func TestDialErrBadStatus(t *testing.T) { } }) - redirect := func(checkRedirect error) *http.Client { + redirectClient := func(checkRedirect error) *http.Client { hc := mockHTTPClient(func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusFound, @@ -199,7 +199,7 @@ func TestDialErrBadStatus(t *testing.T) { errRefused := errors.New("redirect refused") _, resp, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{ - HTTPClient: redirect(errRefused), + HTTPClient: redirectClient(errRefused), }) assert.ErrorIs(t, errRefused, err) assert.Equal(t, "errors.Is(err, ErrBadStatus)", false, errors.Is(err, websocket.ErrBadStatus)) @@ -215,7 +215,7 @@ func TestDialErrBadStatus(t *testing.T) { defer cancel() _, resp, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{ - HTTPClient: redirect(http.ErrUseLastResponse), + HTTPClient: redirectClient(http.ErrUseLastResponse), }) assert.ErrorIs(t, websocket.ErrBadStatus, err) if resp == nil { From 72732d9a79c071cfd845ddd78acc296a553519ac Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:44:19 +0000 Subject: [PATCH 17/17] docs(errors.go): say what the ErrBadStatus unwrap rule keeps ErrBadStatus is new, so "for backward compatibility" alone did not say what is kept. The innermost error is still the status text. --- errors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/errors.go b/errors.go index e6cad3bf..04a0f807 100644 --- a/errors.go +++ b/errors.go @@ -7,7 +7,7 @@ import ( // ErrBadStatus means that the server answered the opening handshake from // Dial with a status other than 101 Switching Protocols. Test for it with // errors.Is. For backward compatibility, the error from Dial does not -// unwrap to it. +// unwrap to it, so its innermost error is still the status text. var ErrBadStatus = errors.New("websocket: bad status") // ErrMessageTooBig is returned when a message exceeds the read limit.