diff --git a/dial.go b/dial.go index 371a7d8e..92820ed6 100644 --- a/dial.go +++ b/dial.go @@ -114,6 +114,12 @@ 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 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 match +// 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. // See docs on the HTTPClient option and https://github.com/golang/go/issues/26937#issuecomment-415855861 @@ -252,7 +258,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, badStatusError{statusCode: resp.StatusCode} } if !headerContainsTokenIgnoreCase(resp.Header, "Connection", "Upgrade") { @@ -278,6 +284,21 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo return verifyServerExtensions(copts, resp.Header) } +// badStatusError keeps the Dial error text unchanged, because callers match +// 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 +} + +func (e badStatusError) Error() string { + return fmt.Sprintf("expected handshake response status code %v but got %v", http.StatusSwitchingProtocols, e.statusCode) +} + +func (badStatusError) Is(target error) bool { + return target == 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 2d1c94d4..11b88ab8 100644 --- a/dial_test.go +++ b/dial_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "crypto/rand" + "errors" "io" "maps" "net/http" @@ -120,6 +121,107 @@ 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)) + }) +} + +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.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") + } + 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("networkError", 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: 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 { + t.Fatalf("expected nil response, got status %d", resp.StatusCode) + } + }) + + redirectClient := 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: redirectClient(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: redirectClient(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) }) } @@ -292,6 +394,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)) }) } } diff --git a/errors.go b/errors.go index bf4fc2b0..04a0f807 100644 --- a/errors.go +++ b/errors.go @@ -4,5 +4,11 @@ import ( "errors" ) +// 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, 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. var ErrMessageTooBig = errors.New("websocket: message too big") diff --git a/ws_js.go b/ws_js.go index 026b75fc..cadcb15e 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 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) if err != nil {